ms-tau-sdk 1.4.0.dev28__tar.gz → 2.0.0.dev29__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 (82) hide show
  1. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/CHANGELOG.md +34 -0
  2. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/PKG-INFO +11 -1
  3. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/README.md +8 -0
  4. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/pyproject.toml +3 -1
  5. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/agent_skills/tau_a2a_runtime_adapter/SKILL.md +3 -0
  6. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/agent_skills/tau_local_development/SKILL.md +4 -0
  7. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/agent_skills/tau_repository_integration/SKILL.md +18 -0
  8. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/a2a.py +65 -1
  9. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/chat.py +8 -1
  10. ms_tau_sdk-2.0.0.dev29/src/ms_tau_sdk/api/request_identity.py +284 -0
  11. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/sessions.py +5 -1
  12. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/app.py +4 -0
  13. ms_tau_sdk-2.0.0.dev29/src/ms_tau_sdk/backend/assertions.py +400 -0
  14. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/logging.py +28 -0
  15. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/provenance.py +26 -11
  16. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/settings.py +74 -0
  17. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/.gitignore +0 -0
  18. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/__init__.py +0 -0
  19. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/app.py +0 -0
  20. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/cli.py +0 -0
  21. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/config.py +0 -0
  22. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/env_file.py +0 -0
  23. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/logs.py +0 -0
  24. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/proxy.py +0 -0
  25. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/state.py +0 -0
  26. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/static/BULMA-LICENSE.txt +0 -0
  27. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/static/app.js +0 -0
  28. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/static/board.css +0 -0
  29. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/static/bulma.min.css +0 -0
  30. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/static/index.html +0 -0
  31. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/__init__.py +0 -0
  32. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/agent_skills/tau_project_customization/SKILL.md +0 -0
  33. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/__init__.py +0 -0
  34. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/conversations.py +0 -0
  35. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/dependencies.py +0 -0
  36. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/health.py +0 -0
  37. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/inspection.py +0 -0
  38. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/local_chat.py +0 -0
  39. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/models.py +0 -0
  40. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/application.py +0 -0
  41. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/__init__.py +0 -0
  42. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/auth.py +0 -0
  43. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/client.py +0 -0
  44. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/local.py +0 -0
  45. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/mcp.py +0 -0
  46. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/models.py +0 -0
  47. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/routes.py +0 -0
  48. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/cli.py +0 -0
  49. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/errors.py +0 -0
  50. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/__init__.py +0 -0
  51. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/a2a_failure.py +0 -0
  52. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
  53. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
  54. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
  55. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/chat_history.py +0 -0
  56. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
  57. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/providers/__init__.py +0 -0
  58. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/providers/definitions.py +0 -0
  59. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/providers/factory.py +0 -0
  60. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/providers/tau_compat.py +0 -0
  61. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
  62. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/resources/__init__.py +0 -0
  63. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/resources/loader.py +0 -0
  64. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/resources/prompts/review-code-repository.md +0 -0
  65. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/__init__.py +0 -0
  66. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
  67. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/events.py +0 -0
  68. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/extensions.py +0 -0
  69. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/failures.py +0 -0
  70. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/live_turns.py +0 -0
  71. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/manager.py +0 -0
  72. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/observability.py +0 -0
  73. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/session.py +0 -0
  74. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
  75. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/task_context.py +0 -0
  76. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/sessions/__init__.py +0 -0
  77. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/sessions/storage.py +0 -0
  78. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/skills.py +0 -0
  79. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/tools/__init__.py +0 -0
  80. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
  81. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/tools/skill_read.py +0 -0
  82. {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/tools/task_control.py +0 -0
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0 — 2026-10-05
4
+
5
+ - `create_app()` installs request identity, so a hosted Agent starts on a platform launcher that
6
+ requires it. The application declares it in `app.state.mainsequence_request_identity`:
7
+ `{"installed": True, "mode": "assertion", "public_ingress": ()}` when Main Sequence hosts the
8
+ runtime and `"mode": "local"` otherwise. Hosted means `MAINSEQUENCE_CALLER_AUTH_MODE=assertion`
9
+ or any of `APP_NAME`, `FASTAPI_PUBLIC_BASE_URL`, `MAINSEQUENCE_CALLER_ASSERTION_ISSUER` or
10
+ `MAINSEQUENCE_CALLER_ASSERTION_JWKS_URL` set, the launcher's own rule. A hosted runtime admits a
11
+ request only with the platform's signed assertion in `X-MainSequence-Caller-Assertion`: a
12
+ platform assertion on `/internal/*`, a caller assertion on every other route, `/health`,
13
+ `/ready` and `/version` included. It verifies the Ed25519 signature against the key set at
14
+ `MAINSEQUENCE_CALLER_ASSERTION_JWKS_URL`, refreshed once for an unknown `kid`, plus the exact
15
+ type, the exact claim set, the issuer, the release in `APP_NAME`, the Environment in
16
+ `MAINSEQUENCE_ORGANIZATION_ENVIRONMENT_UID`, and a lifetime of at most 300 seconds. A missing,
17
+ invalid, duplicated or wrong-kind assertion gets 401, and a key set that cannot be fetched gets
18
+ 503. The runtime no longer takes a caller from `X-User-UID` in hosted mode: a user turn is
19
+ stamped with the verified `sub`, while Agent-caller provenance still comes from the gateway's
20
+ `X-Caller-*` headers. Handlers read the verified caller from `request.state.user`. A hosted
21
+ configuration that lacks a setting fails at `create_app()` and names it, and local mode refuses
22
+ to start with the hosting settings. Outside hosting nothing changes. The SDK now depends directly
23
+ on PyJWT (`crypto`) and `cryptography`, and still not on `mainsequence`. Upgrade note: callers of
24
+ a hosted runtime must go through the platform, which forwards the assertion, and platform probes
25
+ use the launcher's own endpoints. See ADR 0019
26
+ ([#59](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/59)).
27
+ - A hosted runtime lets only a session's owner or an Organization admin address that session. The
28
+ owner is the User the platform recorded as the session's `created_by_user_uid`, compared with the
29
+ verified caller; an Organization admin is a caller whose assertion says `is_organization_admin`.
30
+ Anyone else gets 403 before the runtime acts, on chat, the session model, session cancellation,
31
+ A2A Message send and stream, Task get, cancel, subscribe and list, and the extended Agent Card,
32
+ over REST and JSON-RPC alike. A Task list without `contextId` returns only Tasks of sessions the
33
+ caller may address. The platform's own `/internal/*` calls are not subject to the check, and
34
+ local mode keeps its own owner scope. See ADR 0019
35
+ ([#60](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/60)).
36
+
3
37
  ## 1.4.0 — 2026-10-05
4
38
 
5
39
  - A managed runtime can prove its runtime credential with a projected workload identity token
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ms-tau-sdk
3
- Version: 1.4.0.dev28
3
+ Version: 2.0.0.dev29
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
@@ -14,12 +14,14 @@ Classifier: Programming Language :: Python :: 3.13
14
14
  Requires-Python: >=3.13
15
15
  Requires-Dist: anyio>=4.10
16
16
  Requires-Dist: concurrent-log-handler<1,>=0.9.28
17
+ Requires-Dist: cryptography>=42
17
18
  Requires-Dist: fastapi>=0.116
18
19
  Requires-Dist: httpx>=0.28
19
20
  Requires-Dist: jsonschema<5,>=4.25
20
21
  Requires-Dist: mcp<2,>=1.27
21
22
  Requires-Dist: pydantic-settings>=2.10
22
23
  Requires-Dist: pydantic>=2.11
24
+ Requires-Dist: pyjwt[crypto]<3,>=2.10.1
23
25
  Requires-Dist: python-multipart>=0.0.20
24
26
  Requires-Dist: structlog<27,>=26.1
25
27
  Requires-Dist: tau-ai==0.4.2
@@ -121,6 +123,14 @@ from ms_tau_sdk import create_app
121
123
  app = create_app()
122
124
  ```
123
125
 
126
+ When Main Sequence hosts the runtime, `create_app()` admits a request only with the platform's
127
+ signed assertion in `X-MainSequence-Caller-Assertion`, never on gateway identity headers, and lets
128
+ only a session's owner or an Organization admin address that session. It declares this request
129
+ identity to the platform launcher in `app.state.mainsequence_request_identity`; the launcher serves
130
+ only an application that declares it, so serve the application `create_app()` returns. Outside
131
+ hosting, requests are handled as before. See
132
+ [request identity](docs/reference/runtime-contract.md#request-identity).
133
+
124
134
  Job-hosted batch execution is an [accepted design](docs/adrs/0015-job-hosted-batch-execution.md)
125
135
  with implementation pending. It will run this SDK's configured Tau composition for one assignment
126
136
  inside a project Job, close it, and exit without starting Uvicorn. The Job continues to own its
@@ -90,6 +90,14 @@ from ms_tau_sdk import create_app
90
90
  app = create_app()
91
91
  ```
92
92
 
93
+ When Main Sequence hosts the runtime, `create_app()` admits a request only with the platform's
94
+ signed assertion in `X-MainSequence-Caller-Assertion`, never on gateway identity headers, and lets
95
+ only a session's owner or an Organization admin address that session. It declares this request
96
+ identity to the platform launcher in `app.state.mainsequence_request_identity`; the launcher serves
97
+ only an application that declares it, so serve the application `create_app()` returns. Outside
98
+ hosting, requests are handled as before. See
99
+ [request identity](docs/reference/runtime-contract.md#request-identity).
100
+
93
101
  Job-hosted batch execution is an [accepted design](docs/adrs/0015-job-hosted-batch-execution.md)
94
102
  with implementation pending. It will run this SDK's configured Tau composition for one assignment
95
103
  inside a project Job, close it, and exit without starting Uvicorn. The Job continues to own its
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ms-tau-sdk"
7
- version = "1.4.0.dev28"
7
+ version = "2.0.0.dev29"
8
8
  description = "Workspace-bound Tau application primitives for Main Sequence projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -18,12 +18,14 @@ classifiers = [
18
18
  dependencies = [
19
19
  "anyio>=4.10",
20
20
  "concurrent-log-handler>=0.9.28,<1",
21
+ "cryptography>=42",
21
22
  "fastapi>=0.116",
22
23
  "httpx>=0.28",
23
24
  "jsonschema>=4.25,<5",
24
25
  "mcp>=1.27,<2",
25
26
  "pydantic>=2.11",
26
27
  "pydantic-settings>=2.10",
28
+ "pyjwt[crypto]>=2.10.1,<3",
27
29
  "python-multipart>=0.0.20",
28
30
  "structlog>=26.1,<27",
29
31
  "tau-ai==0.4.2",
@@ -25,6 +25,9 @@ The SDK owns:
25
25
  - preserving each tool's canonical name and metadata through host normalization;
26
26
  - privately attaching active caller-session proof when a tool advertises
27
27
  `mainsequence.ai/requires-caller-session-proof/v1: true`;
28
+ - verifying the platform's signed caller or platform assertion on every inbound request of a
29
+ hosted runtime, and admitting a request to a session or its Tasks only for the session's owner
30
+ or an Organization admin;
28
31
  - translating inbound A2A requests into the shared TAU runtime; and
29
32
  - translating runtime events and results into validated A2A responses.
30
33
 
@@ -35,6 +35,10 @@ The SDK looks for the CLI in `MAINSEQUENCE_CLI`, then beside the Python interpre
35
35
  CLI. Do not add token lines to `.env`. A pair found there still works, but it is deprecated and
36
36
  startup logs a warning. `mainsequence refresh-token` run in that directory removes the lines.
37
37
 
38
+ Do not set `APP_NAME`, `FASTAPI_PUBLIC_BASE_URL`, `MAINSEQUENCE_CALLER_AUTH_MODE=assertion`, or the
39
+ `MAINSEQUENCE_CALLER_ASSERTION_*` variables. The platform sets them when it hosts a runtime, and
40
+ local mode refuses to start with them.
41
+
38
42
  Set `MAINSEQUENCE_ENDPOINT` only for a non-default platform endpoint. The CLI session must be for
39
43
  the same endpoint. Local mode binds to `127.0.0.1:8787` by default. Treat an explicit public bind
40
44
  as privileged exposure: each accepted request can use the authenticated user's live Main Sequence
@@ -44,6 +44,20 @@ Both paths construct the same application. The project owns the dependency lock,
44
44
  deployable artifact, system packages, and any surrounding ASGI composition. There is no separate
45
45
  TAU image, overlay, or SDK-owned deployment.
46
46
 
47
+ `create_app()` installs the runtime's request identity. When Main Sequence hosts the runtime, the
48
+ application admits only requests that carry the platform's signed assertion, lets only a session's
49
+ owner or an Organization admin address that session, and declares all this in
50
+ `app.state.mainsequence_request_identity`. The platform launcher serves an application only with
51
+ that declaration, read from the object it serves. Therefore:
52
+
53
+ - export the object `create_app()` returns as the module's `app`. An application that mounts it
54
+ inside another one declares nothing, and the launcher refuses it;
55
+ - do not add middleware or routes that take the caller from `X-User-UID` or other gateway headers,
56
+ and do not install a second request-identity integration; and
57
+ - do not set `APP_NAME`, `FASTAPI_PUBLIC_BASE_URL`, or the `MAINSEQUENCE_CALLER_*` variables in the
58
+ repository. The platform sets them when it hosts the runtime, and local mode refuses to start
59
+ with them.
60
+
47
61
  ## Job-hosted batch execution
48
62
 
49
63
  ADR 0015 accepts a Python batch entry point, but it is **not implemented in this SDK version**.
@@ -106,7 +120,11 @@ When an ASGI shim exists, also import it without starting a second application i
106
120
 
107
121
  ```bash
108
122
  uv run python -c "from api.tau.main import app; print(type(app).__name__)"
123
+ uv run python -c "from api.tau.main import app; print(app.state.mainsequence_request_identity)"
109
124
  ```
110
125
 
126
+ The second command prints `{'installed': True, 'mode': 'local', 'public_ingress': ()}` outside
127
+ hosting.
128
+
111
129
  Do not require the `mainsequence` Python distribution in the TAU dependency graph. Authentication
112
130
  and Main Sequence transport are implemented directly by `ms-tau-sdk`.
@@ -70,6 +70,12 @@ from ms_tau_sdk.runtime.task_context import (
70
70
  from ms_tau_sdk.settings import TauSDKSettings
71
71
 
72
72
  from .dependencies import backend, runtime_manager, settings
73
+ from .request_identity import (
74
+ SessionAccessDeniedError,
75
+ require_session_access,
76
+ sessions_the_caller_may_address,
77
+ verified_user_uid,
78
+ )
73
79
 
74
80
  router = APIRouter()
75
81
  BackendDep = Annotated[MainSequenceClient, Depends(backend)]
@@ -471,7 +477,31 @@ def _a2a_turn_provenance(
471
477
  "actorKind": "agent",
472
478
  "actorUid": f"local-a2a-client-{config.workspace_digest}",
473
479
  }
474
- return turn_provenance_from_request("a2a", request.headers)
480
+ return turn_provenance_from_request(
481
+ "a2a",
482
+ request.headers,
483
+ verified_user_uid=verified_user_uid(config),
484
+ )
485
+
486
+
487
+ def _requested_context_id(body: dict[str, Any]) -> str:
488
+ """Return the session a Message request names, before any of its parts is prepared."""
489
+
490
+ message = body.get("message")
491
+ return str(message.get("contextId") or "").strip() if isinstance(message, dict) else ""
492
+
493
+
494
+ async def _require_task_session_access(
495
+ client: MainSequenceClient,
496
+ task: AgentTask,
497
+ *,
498
+ checked: str = "",
499
+ ) -> None:
500
+ """Admit the request to the session of ``task`` unless that session was already checked."""
501
+
502
+ session_uid = task.agent_session_uid or task.context_id
503
+ if not session_uid or session_uid != checked:
504
+ await require_session_access(client, session_uid)
475
505
 
476
506
 
477
507
  def _output_contract(body: dict[str, Any]) -> StrictJsonContract:
@@ -2204,6 +2234,9 @@ async def message_send(
2204
2234
  provenance = _a2a_turn_provenance(config, request)
2205
2235
  except CallerIdentityError as error:
2206
2236
  return _caller_identity_rejection(request, error) # type: ignore[return-value]
2237
+ requested_context_id = _requested_context_id(body)
2238
+ if requested_context_id:
2239
+ await require_session_access(client, requested_context_id)
2207
2240
  message, prompt = _request_parts(body, config)
2208
2241
  history_length = _body_history_length(body)
2209
2242
  _bind_a2a_context(
@@ -2233,6 +2266,7 @@ async def message_send(
2233
2266
  detail="A Task continuation requires configuration.responseKind 'task'",
2234
2267
  )
2235
2268
  existing_task = await client.get_task_by_protocol_id(continuation_task_id)
2269
+ await _require_task_session_access(client, existing_task, checked=requested_context_id)
2236
2270
  continued_message = _task_message_for_backend(
2237
2271
  message,
2238
2272
  context_id=existing_task.context_id,
@@ -2300,6 +2334,8 @@ async def message_send(
2300
2334
  )
2301
2335
  task = await _task_with_history(client, task, history_length=history_length)
2302
2336
  return {"task": _task_payload(task, history_length=history_length)}
2337
+ # An existing Task with this ID is answered only to a caller who may address its session.
2338
+ await _require_task_session_access(client, task, checked=requested_context_id)
2303
2339
  if config.local_mode and task.status == "submitted":
2304
2340
  recovered_prompt, recovered_contract, recovered_provenance = (
2305
2341
  _recovery_execution_contract(task, config)
@@ -2367,6 +2403,9 @@ async def message_stream(
2367
2403
  a2a_extensions=a2a_extensions,
2368
2404
  streaming=True,
2369
2405
  )
2406
+ requested_context_id = _requested_context_id(body)
2407
+ if requested_context_id:
2408
+ await require_session_access(client, requested_context_id)
2370
2409
  message, prompt = _request_parts(body, config)
2371
2410
  history_length = _body_history_length(body)
2372
2411
  _bind_a2a_context(
@@ -2389,6 +2428,7 @@ async def message_stream(
2389
2428
  )
2390
2429
  task = creation.task
2391
2430
  if not creation.created:
2431
+ await _require_task_session_access(client, task, checked=requested_context_id)
2392
2432
  if config.local_mode and task.status == "submitted":
2393
2433
  recovered_prompt, recovered_contract, recovered_provenance = (
2394
2434
  _recovery_execution_contract(task, config)
@@ -2428,10 +2468,22 @@ async def list_tasks(
2428
2468
  historyLength: int | None = Query(default=None, ge=0),
2429
2469
  ) -> dict[str, Any]:
2430
2470
  history_length = _history_length(historyLength)
2471
+ if contextId:
2472
+ await require_session_access(client, contextId)
2431
2473
  tasks = await client.list_tasks(
2432
2474
  context_id=contextId or "",
2433
2475
  history_length=history_length,
2434
2476
  )
2477
+ if not contextId:
2478
+ # A list across sessions keeps only the Tasks of sessions the caller may address.
2479
+ visible = await sessions_the_caller_may_address(
2480
+ client,
2481
+ (task.agent_session_uid or task.context_id for task in tasks),
2482
+ )
2483
+ if visible is not None:
2484
+ tasks = [
2485
+ task for task in tasks if (task.agent_session_uid or task.context_id) in visible
2486
+ ]
2435
2487
  return {"tasks": [_task_payload(task, history_length=history_length) for task in tasks]}
2436
2488
 
2437
2489
 
@@ -2442,6 +2494,7 @@ async def cancel_task(
2442
2494
  manager: RuntimeManagerDep,
2443
2495
  ) -> dict[str, Any]:
2444
2496
  task = await client.get_task_by_protocol_id(task_id)
2497
+ await _require_task_session_access(client, task)
2445
2498
  await manager.cancel(task.context_id)
2446
2499
  return {"task": _task_payload(await client.cancel_task(task.uid))}
2447
2500
 
@@ -2454,6 +2507,7 @@ async def subscribe_task(
2454
2507
  after_sequence: int | None = Query(default=None, alias="afterSequence", ge=0),
2455
2508
  ) -> StreamingResponse | JSONResponse:
2456
2509
  snapshot = await client.get_task_snapshot_by_protocol_id(task_id)
2510
+ await _require_task_session_access(client, snapshot.task)
2457
2511
  if snapshot.task.status in TERMINAL:
2458
2512
  return _unsupported_operation_rest_error(
2459
2513
  "SubscribeToTask is unavailable for a terminal Task; use GetTask."
@@ -2474,6 +2528,7 @@ async def get_task(
2474
2528
  ) -> dict[str, Any]:
2475
2529
  history_length = _history_length(historyLength)
2476
2530
  task = await client.get_task_by_protocol_id(task_id, history_length=history_length)
2531
+ await _require_task_session_access(client, task)
2477
2532
  return {"task": _task_payload(task, history_length=history_length)}
2478
2533
 
2479
2534
 
@@ -2513,6 +2568,7 @@ async def extended_agent_card(
2513
2568
  resolved_uid = agent_session_uid or session_uid or context_id
2514
2569
  if not resolved_uid:
2515
2570
  raise HTTPException(status_code=400, detail="agent_session_uid is required")
2571
+ await require_session_access(client, resolved_uid)
2516
2572
  envelope = await client.get_agent_card(resolved_uid)
2517
2573
  payload = envelope.model_dump(mode="json")
2518
2574
  payload["agent_card"] = _effective_agent_card(envelope.agent_card)
@@ -2590,6 +2646,9 @@ async def json_rpc(
2590
2646
  a2a_extensions=a2a_extensions,
2591
2647
  streaming=True,
2592
2648
  )
2649
+ requested_context_id = _requested_context_id(params)
2650
+ if requested_context_id:
2651
+ await require_session_access(client, requested_context_id)
2593
2652
  message, prompt = _request_parts(params, config)
2594
2653
  history_length = _body_history_length(params)
2595
2654
  _bind_a2a_context(
@@ -2613,6 +2672,7 @@ async def json_rpc(
2613
2672
  )
2614
2673
  task = creation.task
2615
2674
  if not creation.created:
2675
+ await _require_task_session_access(client, task, checked=requested_context_id)
2616
2676
  if config.local_mode and task.status == "submitted":
2617
2677
  recovered_prompt, recovered_contract, recovered_provenance = (
2618
2678
  _recovery_execution_contract(task, config)
@@ -2669,6 +2729,7 @@ async def json_rpc(
2669
2729
  )
2670
2730
  elif method in {"SubscribeToTask", "tasks/subscribe"}:
2671
2731
  snapshot = await client.get_task_snapshot_by_protocol_id(task_id())
2732
+ await _require_task_session_access(client, snapshot.task)
2672
2733
  if snapshot.task.status in TERMINAL:
2673
2734
  return _unsupported_operation_json_rpc_error(
2674
2735
  request_id,
@@ -2698,6 +2759,9 @@ async def json_rpc(
2698
2759
  "error": {"code": -32601, "message": "Method not found"},
2699
2760
  }
2700
2761
  return {"jsonrpc": "2.0", "id": request_id, "result": result}
2762
+ except SessionAccessDeniedError:
2763
+ # Refused access to a session is answered as HTTP 403 on every transport.
2764
+ raise
2701
2765
  except HTTPException as error:
2702
2766
  return {
2703
2767
  "jsonrpc": "2.0",
@@ -22,6 +22,7 @@ from ms_tau_sdk.runtime.provenance import (
22
22
 
23
23
  from .dependencies import runtime_manager
24
24
  from .models import ChatRequest
25
+ from .request_identity import require_session_access, verified_user_uid
25
26
 
26
27
  router = APIRouter(prefix="/api")
27
28
  RuntimeManagerDep = Annotated[SessionRuntimeManager, Depends(runtime_manager)]
@@ -61,7 +62,11 @@ async def chat(
61
62
  }
62
63
  else:
63
64
  try:
64
- provenance = turn_provenance_from_request("chat", request.headers)
65
+ provenance = turn_provenance_from_request(
66
+ "chat",
67
+ request.headers,
68
+ verified_user_uid=verified_user_uid(manager.settings),
69
+ )
65
70
  except CallerIdentityError as error:
66
71
  logger.warning(
67
72
  "turn.caller_identity_rejected",
@@ -77,6 +82,8 @@ async def chat(
77
82
  session_uid = manager.settings.local_session_uid(body.session_uid)
78
83
  elif body.session_uid:
79
84
  session_uid = body.session_uid
85
+ if manager.settings.request_identity_mode == "assertion":
86
+ await require_session_access(manager.backend, session_uid)
80
87
  else:
81
88
  raise HTTPException(status_code=422, detail="sessionUid is required")
82
89
  try:
@@ -0,0 +1,284 @@
1
+ """Request identity: who calls the runtime, and which sessions that caller may address.
2
+
3
+ ``create_app()`` declares request identity for the platform launcher in
4
+ ``app.state.mainsequence_request_identity``. In hosted mode
5
+ (``TauSDKSettings.request_identity_mode == "assertion"``) it also installs
6
+ ``RequestIdentityMiddleware``, which admits a request only with the platform's signed assertion of
7
+ the kind its route requires: a platform assertion on ``/internal/*``, a caller assertion on every
8
+ other route. Handlers read the verified caller with ``current_caller()``, or from
9
+ ``request.state.user`` and ``request.state.user_uid``.
10
+
11
+ A request that addresses an existing session must also come from the session's owner or from an
12
+ Organization admin; see ``require_session_access()``. Outside hosted mode nothing here changes how
13
+ requests are handled.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Iterable
19
+ from contextvars import ContextVar
20
+
21
+ import structlog
22
+ from fastapi import FastAPI, HTTPException
23
+ from starlette.responses import JSONResponse
24
+ from starlette.types import ASGIApp, Receive, Scope, Send
25
+ from starlette.websockets import WebSocketClose
26
+
27
+ from ms_tau_sdk.backend.assertions import (
28
+ ASSERTION_HEADER,
29
+ AssertionKeysUnavailableError,
30
+ AssertionKind,
31
+ AssertionVerifier,
32
+ InvalidAssertionError,
33
+ VerifiedCaller,
34
+ )
35
+ from ms_tau_sdk.backend.client import MainSequenceClient
36
+ from ms_tau_sdk.backend.models import AgentSession
37
+ from ms_tau_sdk.errors import SessionNotFoundError
38
+ from ms_tau_sdk.logging import bind_request_identity_log_fields
39
+ from ms_tau_sdk.runtime.provenance import CallerIdentityError
40
+ from ms_tau_sdk.settings import TauSDKSettings
41
+
42
+ logger = structlog.get_logger(__name__)
43
+
44
+ # The platform launcher reads this declaration from the application state before it serves the
45
+ # application. Its name and keys are the launcher's.
46
+ REQUEST_IDENTITY_STATE_ATTRIBUTE = "mainsequence_request_identity"
47
+ INTERNAL_ROUTE_PREFIX = "/internal"
48
+ _ASSERTION_HEADER_NAME = ASSERTION_HEADER.lower().encode("latin-1")
49
+ _REJECTION_DETAIL: dict[AssertionKind, str] = {
50
+ "caller": "A valid caller assertion is required.",
51
+ "platform": "A valid platform assertion is required.",
52
+ }
53
+ _UNAVAILABLE_DETAIL = "Caller authentication is unavailable."
54
+ SESSION_ACCESS_DENIED_DETAIL = (
55
+ "Only the session's owner or an Organization admin can address this session."
56
+ )
57
+
58
+ _current_caller: ContextVar[VerifiedCaller | None] = ContextVar(
59
+ "ms_tau_sdk_verified_caller",
60
+ default=None,
61
+ )
62
+
63
+
64
+ class SessionAccessDeniedError(HTTPException):
65
+ """A verified caller addressed a session that it neither owns nor administers."""
66
+
67
+ def __init__(self) -> None:
68
+ super().__init__(status_code=403, detail=SESSION_ACCESS_DENIED_DETAIL)
69
+
70
+
71
+ def current_caller() -> VerifiedCaller | None:
72
+ """Return the caller that the current request's verified caller assertion names.
73
+
74
+ It is None outside hosted mode and on the platform's own calls to ``/internal/*``.
75
+ """
76
+
77
+ return _current_caller.get()
78
+
79
+
80
+ def verified_user_uid(settings: TauSDKSettings) -> str | None:
81
+ """Return the verified caller's User UID in hosted mode, and None otherwise.
82
+
83
+ In hosted mode a request without a verified caller raises ``CallerIdentityError``: the
84
+ caller is never taken from a header instead.
85
+ """
86
+
87
+ if settings.request_identity_mode != "assertion":
88
+ return None
89
+ caller = current_caller()
90
+ if caller is None:
91
+ raise CallerIdentityError((ASSERTION_HEADER,))
92
+ return caller.uid
93
+
94
+
95
+ async def require_session_access(
96
+ client: MainSequenceClient,
97
+ session_uid: str,
98
+ ) -> AgentSession | None:
99
+ """Admit the current request to ``session_uid`` only for its owner or an Organization admin.
100
+
101
+ The owner is the User the platform recorded as the session's creator,
102
+ ``created_by_user_uid``. The check applies in hosted mode only and raises
103
+ ``SessionAccessDeniedError`` (403). It returns the session when it had to read it.
104
+ """
105
+
106
+ if client.settings.request_identity_mode != "assertion":
107
+ return None
108
+ caller = current_caller()
109
+ if caller is None or not session_uid:
110
+ raise SessionAccessDeniedError()
111
+ if caller.is_organization_admin:
112
+ return None
113
+ session = await client.get_session(session_uid)
114
+ if session.created_by_user_uid != caller.uid:
115
+ raise SessionAccessDeniedError()
116
+ return session
117
+
118
+
119
+ async def sessions_the_caller_may_address(
120
+ client: MainSequenceClient,
121
+ session_uids: Iterable[str],
122
+ ) -> set[str] | None:
123
+ """Return which of ``session_uids`` the current caller may address.
124
+
125
+ None means every session: outside hosted mode, or for an Organization admin. A session that
126
+ no longer exists is left out.
127
+ """
128
+
129
+ if client.settings.request_identity_mode != "assertion":
130
+ return None
131
+ caller = current_caller()
132
+ if caller is None:
133
+ return set()
134
+ if caller.is_organization_admin:
135
+ return None
136
+ allowed: set[str] = set()
137
+ for session_uid in set(session_uids):
138
+ if not session_uid:
139
+ continue
140
+ try:
141
+ session = await client.get_session(session_uid)
142
+ except SessionNotFoundError:
143
+ continue
144
+ if session.created_by_user_uid == caller.uid:
145
+ allowed.add(session_uid)
146
+ return allowed
147
+
148
+
149
+ def _route_path(scope: Scope) -> str:
150
+ """Return the path the router matches, the same way Starlette's routing derives it."""
151
+
152
+ path = str(scope.get("path") or "")
153
+ root_path = str(scope.get("root_path") or "")
154
+ if root_path and path.startswith(root_path) and path != root_path:
155
+ if path[len(root_path)] == "/":
156
+ return path[len(root_path) :]
157
+ return path
158
+
159
+
160
+ def required_assertion(scope: Scope) -> AssertionKind:
161
+ """Name the assertion a route requires: a platform assertion on ``/internal/*`` only."""
162
+
163
+ path = _route_path(scope)
164
+ internal = path == INTERNAL_ROUTE_PREFIX or path.startswith(INTERNAL_ROUTE_PREFIX + "/")
165
+ return "platform" if internal else "caller"
166
+
167
+
168
+ def _assertion(scope: Scope) -> str:
169
+ values = [
170
+ value for name, value in scope.get("headers", ()) if name.lower() == _ASSERTION_HEADER_NAME
171
+ ]
172
+ if len(values) > 1:
173
+ raise InvalidAssertionError("The request carries more than one assertion")
174
+ token = values[0].decode("latin-1").strip() if values else ""
175
+ if not token:
176
+ raise InvalidAssertionError("The request carries no assertion")
177
+ return token
178
+
179
+
180
+ class RequestIdentityMiddleware:
181
+ """Admit a hosted runtime's requests only with the platform's signed assertion."""
182
+
183
+ def __init__(self, app: ASGIApp, *, verifier: AssertionVerifier) -> None:
184
+ self.app = app
185
+ self.verifier = verifier
186
+
187
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
188
+ if scope["type"] not in {"http", "websocket"}:
189
+ await self.app(scope, receive, send)
190
+ return
191
+ state = scope.setdefault("state", {})
192
+ if scope["type"] == "http" and scope.get("method") == "OPTIONS":
193
+ state.update(user=None, user_uid=None, auth_outcome="not_applicable")
194
+ bind_request_identity_log_fields(scope, user_uid=None, auth_outcome="not_applicable")
195
+ await self.app(scope, receive, send)
196
+ return
197
+ kind = required_assertion(scope)
198
+ try:
199
+ verified = await self.verifier.verify(_assertion(scope), kind=kind)
200
+ except AssertionKeysUnavailableError as error:
201
+ await self._deny(scope, receive, send, kind=kind, status_code=503, reason=str(error))
202
+ return
203
+ except InvalidAssertionError as error:
204
+ await self._deny(scope, receive, send, kind=kind, status_code=401, reason=str(error))
205
+ return
206
+ caller = verified.caller
207
+ user_uid = caller.uid if caller is not None else None
208
+ state.update(
209
+ user=caller,
210
+ user_uid=user_uid,
211
+ auth_outcome="authenticated",
212
+ resource_release_uid=self.verifier.resource_release_uid,
213
+ organization_environment_uid=self.verifier.organization_environment_uid,
214
+ )
215
+ bind_request_identity_log_fields(scope, user_uid=user_uid, auth_outcome="authenticated")
216
+ token = _current_caller.set(caller)
217
+ try:
218
+ await self.app(scope, receive, send)
219
+ finally:
220
+ _current_caller.reset(token)
221
+
222
+ async def _deny(
223
+ self,
224
+ scope: Scope,
225
+ receive: Receive,
226
+ send: Send,
227
+ *,
228
+ kind: AssertionKind,
229
+ status_code: int,
230
+ reason: str,
231
+ ) -> None:
232
+ outcome = "unavailable" if status_code == 503 else "rejected"
233
+ scope["state"].update(user=None, user_uid=None, auth_outcome=outcome)
234
+ bind_request_identity_log_fields(scope, user_uid=None, auth_outcome=outcome)
235
+ logger.warning(
236
+ "request_identity.rejected",
237
+ message="Request identity rejected a request",
238
+ required_assertion=kind,
239
+ reason=reason,
240
+ status_code=status_code,
241
+ )
242
+ if scope["type"] == "websocket":
243
+ await WebSocketClose(code=1013 if status_code == 503 else 1008)(scope, receive, send)
244
+ return
245
+ response = JSONResponse(
246
+ status_code=status_code,
247
+ content={
248
+ "detail": _UNAVAILABLE_DETAIL if status_code == 503 else _REJECTION_DETAIL[kind]
249
+ },
250
+ headers={"Cache-Control": "no-store"},
251
+ )
252
+ await response(scope, receive, send)
253
+
254
+
255
+ def install_request_identity(app: FastAPI, settings: TauSDKSettings) -> None:
256
+ """Declare request identity for the platform launcher and, when hosted, enforce it.
257
+
258
+ Call it before any other middleware is added, so that it is the innermost one.
259
+ """
260
+
261
+ mode = settings.request_identity_mode
262
+ if mode == "assertion":
263
+ app.add_middleware(
264
+ RequestIdentityMiddleware,
265
+ verifier=AssertionVerifier.from_settings(settings),
266
+ )
267
+ setattr(
268
+ app.state,
269
+ REQUEST_IDENTITY_STATE_ATTRIBUTE,
270
+ {"installed": True, "mode": mode, "public_ingress": ()},
271
+ )
272
+
273
+
274
+ __all__ = [
275
+ "REQUEST_IDENTITY_STATE_ATTRIBUTE",
276
+ "RequestIdentityMiddleware",
277
+ "SessionAccessDeniedError",
278
+ "current_caller",
279
+ "install_request_identity",
280
+ "require_session_access",
281
+ "required_assertion",
282
+ "sessions_the_caller_may_address",
283
+ "verified_user_uid",
284
+ ]