managed-deepagents 0.6.2.dev37__py3-none-win_amd64.whl → 0.6.2.dev38__py3-none-win_amd64.whl
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.
- managed_deepagents/_agent_auth.py +49 -39
- managed_deepagents/_agent_auth_http.py +41 -9
- managed_deepagents/_binaries/mda.exe +0 -0
- managed_deepagents/_channels/trigger/http.py +122 -6
- managed_deepagents/_channels/trigger/identity_context.py +214 -0
- managed_deepagents/_channels/trigger/types.py +12 -2
- managed_deepagents/_connections.py +5 -12
- managed_deepagents/_credential_gate.py +2 -2
- managed_deepagents/_identity_auth.py +49 -7
- managed_deepagents/_ingress.py +46 -14
- {managed_deepagents-0.6.2.dev37.dist-info → managed_deepagents-0.6.2.dev38.dist-info}/METADATA +4 -2
- {managed_deepagents-0.6.2.dev37.dist-info → managed_deepagents-0.6.2.dev38.dist-info}/RECORD +16 -15
- {managed_deepagents-0.6.2.dev37.dist-info → managed_deepagents-0.6.2.dev38.dist-info}/WHEEL +0 -0
- {managed_deepagents-0.6.2.dev37.dist-info → managed_deepagents-0.6.2.dev38.dist-info}/entry_points.txt +0 -0
- {managed_deepagents-0.6.2.dev37.dist-info → managed_deepagents-0.6.2.dev38.dist-info}/licenses/LICENSE +0 -0
- {managed_deepagents-0.6.2.dev37.dist-info → managed_deepagents-0.6.2.dev38.dist-info}/top_level.txt +0 -0
|
@@ -23,6 +23,7 @@ AGENT_AUTH_PROVIDER_PAGE_SIZE = 100
|
|
|
23
23
|
AGENT_AUTH_MAX_PROVIDER_PAGES = 20
|
|
24
24
|
|
|
25
25
|
_HTTP_BAD_REQUEST = 400
|
|
26
|
+
_HTTP_FORBIDDEN = 403
|
|
26
27
|
_HTTP_NOT_FOUND = 404
|
|
27
28
|
_HTTP_CONFLICT = 409
|
|
28
29
|
|
|
@@ -46,8 +47,6 @@ class AgentAuthContext:
|
|
|
46
47
|
principal_id: str
|
|
47
48
|
api_key: str | None = None
|
|
48
49
|
workspace_id: str | None = None
|
|
49
|
-
#: Agent Builder service JWT for workspace-owned credential material.
|
|
50
|
-
service_key: str | None = None
|
|
51
50
|
|
|
52
51
|
|
|
53
52
|
class PendingAuthorization(TypedDict):
|
|
@@ -134,7 +133,7 @@ class AgentAuth:
|
|
|
134
133
|
f"/v1/agent-auth/connections?{urllib.parse.urlencode(query)}",
|
|
135
134
|
)
|
|
136
135
|
if status >= _HTTP_BAD_REQUEST:
|
|
137
|
-
raise CredentialStoreError("Agent Auth list connections failed", status)
|
|
136
|
+
raise CredentialStoreError("Agent Auth list connections failed", status, body)
|
|
138
137
|
raw_items = body.get("items")
|
|
139
138
|
items = raw_items if isinstance(raw_items, list) else []
|
|
140
139
|
for item in items:
|
|
@@ -170,28 +169,17 @@ class AgentAuth:
|
|
|
170
169
|
def find_secret_credential_id(self, connection: WorkspaceConnection, slug: str) -> str | None:
|
|
171
170
|
"""Resolve the credential id for a secret connection row.
|
|
172
171
|
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
Agent-owned connection declarations scope the credential to this
|
|
173
|
+
deployment. Workspace-owned credentials are deliberately not visible
|
|
174
|
+
to deployments.
|
|
175
175
|
"""
|
|
176
176
|
if connection.kind != "secret":
|
|
177
177
|
return None
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (self.context.service_key or "").strip():
|
|
184
|
-
from_owner = self._credential_id_for_owner(
|
|
185
|
-
connection.connection_id, "workspace", workspace_id
|
|
186
|
-
)
|
|
187
|
-
if from_owner is not None:
|
|
188
|
-
return from_owner
|
|
189
|
-
return self.find_credential_id(
|
|
190
|
-
owner_type="workspace",
|
|
191
|
-
owner_id=workspace_id,
|
|
192
|
-
kind="secret",
|
|
193
|
-
slug=slug,
|
|
194
|
-
)
|
|
178
|
+
from_agent = self._credential_id_for_owner(
|
|
179
|
+
connection.connection_id, "agent", self.context.agent_id
|
|
180
|
+
)
|
|
181
|
+
if from_agent is not None:
|
|
182
|
+
return from_agent
|
|
195
183
|
return self.find_credential_id(
|
|
196
184
|
owner_type="agent",
|
|
197
185
|
owner_id=self.context.agent_id,
|
|
@@ -202,6 +190,12 @@ class AgentAuth:
|
|
|
202
190
|
def _credential_id_for_owner(
|
|
203
191
|
self, connection_id: str, owner_type: str, owner_id: str
|
|
204
192
|
) -> str | None:
|
|
193
|
+
"""Read credential_id from GET /connections/{id}?credential_owner_*.
|
|
194
|
+
|
|
195
|
+
Deployments authenticate with ``LANGSMITH_API_KEY`` (the minted
|
|
196
|
+
service-account key). User and workspace owners additionally require
|
|
197
|
+
workspace management permission.
|
|
198
|
+
"""
|
|
205
199
|
query = urllib.parse.urlencode(
|
|
206
200
|
{
|
|
207
201
|
"credential_owner_type": owner_type,
|
|
@@ -215,16 +209,27 @@ class AgentAuth:
|
|
|
215
209
|
if status == _HTTP_NOT_FOUND:
|
|
216
210
|
return None
|
|
217
211
|
if status >= _HTTP_BAD_REQUEST:
|
|
218
|
-
raise CredentialStoreError(
|
|
212
|
+
raise CredentialStoreError(
|
|
213
|
+
"Agent Auth get connection for owner failed"
|
|
214
|
+
+ (" (forbidden)" if status == _HTTP_FORBIDDEN else ""),
|
|
215
|
+
status,
|
|
216
|
+
body,
|
|
217
|
+
)
|
|
219
218
|
return _connection_credential_id(body)
|
|
220
219
|
|
|
221
220
|
def read_user_secret(self, slug: str) -> str | None:
|
|
222
221
|
"""Read a caller-owned opaque secret grant for a workspace connection slug."""
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
222
|
+
connection = self.find_workspace_connection(slug)
|
|
223
|
+
if connection is None:
|
|
224
|
+
return None
|
|
225
|
+
return self.read_user_secret_for_connection(connection)
|
|
226
|
+
|
|
227
|
+
def read_user_secret_for_connection(self, connection: WorkspaceConnection) -> str | None:
|
|
228
|
+
"""Read a caller-owned opaque secret when the connection row is already loaded."""
|
|
229
|
+
if connection.kind != "secret":
|
|
230
|
+
return None
|
|
231
|
+
credential_id = self._credential_id_for_owner(
|
|
232
|
+
connection.connection_id, "user", self.context.principal_id
|
|
228
233
|
)
|
|
229
234
|
if credential_id is None:
|
|
230
235
|
return None
|
|
@@ -244,11 +249,17 @@ class AgentAuth:
|
|
|
244
249
|
Returns:
|
|
245
250
|
The access token, or ``None`` when the caller must connect.
|
|
246
251
|
"""
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
+
connection = self.find_workspace_connection(slug)
|
|
253
|
+
if connection is None:
|
|
254
|
+
return None
|
|
255
|
+
return self.read_user_access_token_for_connection(connection)
|
|
256
|
+
|
|
257
|
+
def read_user_access_token_for_connection(self, connection: WorkspaceConnection) -> str | None:
|
|
258
|
+
"""Read a caller OAuth token when the connection row is already loaded."""
|
|
259
|
+
if connection.kind != "oauth2":
|
|
260
|
+
return None
|
|
261
|
+
credential_id = self._credential_id_for_owner(
|
|
262
|
+
connection.connection_id, "user", self.context.principal_id
|
|
252
263
|
)
|
|
253
264
|
if credential_id is None:
|
|
254
265
|
return None
|
|
@@ -272,7 +283,7 @@ class AgentAuth:
|
|
|
272
283
|
)
|
|
273
284
|
if status >= _HTTP_BAD_REQUEST:
|
|
274
285
|
raise CredentialStoreError(
|
|
275
|
-
"Agent Auth create oauth-authorization-session failed", status
|
|
286
|
+
"Agent Auth create oauth-authorization-session failed", status, body
|
|
276
287
|
)
|
|
277
288
|
auth_id = str(body.get("id") or "")
|
|
278
289
|
connect_url = str(body.get("verification_url") or "")
|
|
@@ -293,7 +304,7 @@ class AgentAuth:
|
|
|
293
304
|
"GET", f"/v1/agent-auth/oauth-providers?{urllib.parse.urlencode(query)}"
|
|
294
305
|
)
|
|
295
306
|
if status >= _HTTP_BAD_REQUEST:
|
|
296
|
-
raise CredentialStoreError("Agent Auth list oauth-providers failed", status)
|
|
307
|
+
raise CredentialStoreError("Agent Auth list oauth-providers failed", status, body)
|
|
297
308
|
raw_items = body.get("items")
|
|
298
309
|
for item in raw_items if isinstance(raw_items, list) else []:
|
|
299
310
|
if isinstance(item, Mapping) and item.get("slug") == slug and item.get("id"):
|
|
@@ -329,7 +340,7 @@ class AgentAuth:
|
|
|
329
340
|
)
|
|
330
341
|
status, body = self._request("GET", f"/v1/agent-auth/credentials?{query}")
|
|
331
342
|
if status >= _HTTP_BAD_REQUEST:
|
|
332
|
-
raise CredentialStoreError("Agent Auth list credentials failed", status)
|
|
343
|
+
raise CredentialStoreError("Agent Auth list credentials failed", status, body)
|
|
333
344
|
raw_items = body.get("items")
|
|
334
345
|
items = raw_items if isinstance(raw_items, list) else []
|
|
335
346
|
for item in items:
|
|
@@ -352,7 +363,7 @@ class AgentAuth:
|
|
|
352
363
|
if status == _HTTP_NOT_FOUND:
|
|
353
364
|
return None
|
|
354
365
|
if status >= _HTTP_BAD_REQUEST:
|
|
355
|
-
raise CredentialStoreError("Agent Auth get secret failed", status)
|
|
366
|
+
raise CredentialStoreError("Agent Auth get secret failed", status, body)
|
|
356
367
|
secret = body.get("secret")
|
|
357
368
|
return secret if isinstance(secret, str) and secret else None
|
|
358
369
|
|
|
@@ -364,7 +375,7 @@ class AgentAuth:
|
|
|
364
375
|
if _is_authorization_required(status, body):
|
|
365
376
|
return None
|
|
366
377
|
if status >= _HTTP_BAD_REQUEST:
|
|
367
|
-
raise CredentialStoreError("Agent Auth get oauth2 failed", status)
|
|
378
|
+
raise CredentialStoreError("Agent Auth get oauth2 failed", status, body)
|
|
368
379
|
oauth2 = body.get("oauth2") if isinstance(body.get("oauth2"), dict) else body
|
|
369
380
|
token = oauth2.get("access_token") if isinstance(oauth2, dict) else None
|
|
370
381
|
return token if isinstance(token, str) and token else None
|
|
@@ -390,7 +401,6 @@ class AgentAuth:
|
|
|
390
401
|
return agent_auth_headers(
|
|
391
402
|
api_key=self.context.api_key,
|
|
392
403
|
workspace_id=self.context.workspace_id,
|
|
393
|
-
service_key=self.context.service_key,
|
|
394
404
|
)
|
|
395
405
|
|
|
396
406
|
|
|
@@ -11,32 +11,68 @@ import urllib.error
|
|
|
11
11
|
import urllib.parse
|
|
12
12
|
import urllib.request
|
|
13
13
|
from collections.abc import Mapping
|
|
14
|
-
from typing import TYPE_CHECKING
|
|
14
|
+
from typing import TYPE_CHECKING, Final
|
|
15
15
|
|
|
16
16
|
if TYPE_CHECKING:
|
|
17
17
|
from http.client import HTTPMessage
|
|
18
18
|
from typing import IO
|
|
19
19
|
|
|
20
20
|
AGENT_AUTH_TIMEOUT_SECONDS = 10.0
|
|
21
|
+
AGENT_AUTH_PROBLEM_HINT_MAX_CHARS = 200
|
|
22
|
+
_PROBLEM_HINT_FIELDS: Final = ("detail", "remedy")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def agent_auth_problem_hint(body: Mapping[str, object] | None) -> str | None:
|
|
26
|
+
"""Return the caller-facing part of an Agent Auth problem+json body.
|
|
27
|
+
|
|
28
|
+
``detail`` names the failure and ``remedy`` names the fix. Nothing else
|
|
29
|
+
from a response body is read, because the rest can quote secret material.
|
|
30
|
+
Both are collapsed to one line and truncated so a long or multi-line body
|
|
31
|
+
cannot reshape an error message.
|
|
32
|
+
"""
|
|
33
|
+
seen: dict[str, None] = {}
|
|
34
|
+
for field in _PROBLEM_HINT_FIELDS:
|
|
35
|
+
value = None if body is None else body.get(field)
|
|
36
|
+
if not isinstance(value, str):
|
|
37
|
+
continue
|
|
38
|
+
text = " ".join(value.split())
|
|
39
|
+
if text:
|
|
40
|
+
seen[text] = None
|
|
41
|
+
if not seen:
|
|
42
|
+
return None
|
|
43
|
+
hint = " ".join(seen)
|
|
44
|
+
if len(hint) > AGENT_AUTH_PROBLEM_HINT_MAX_CHARS:
|
|
45
|
+
return hint[:AGENT_AUTH_PROBLEM_HINT_MAX_CHARS] + "..."
|
|
46
|
+
return hint
|
|
21
47
|
|
|
22
48
|
|
|
23
49
|
class CredentialStoreError(RuntimeError):
|
|
24
50
|
"""Agent Auth could not answer.
|
|
25
51
|
|
|
26
|
-
Callers pass a fixed message
|
|
27
|
-
|
|
52
|
+
Callers pass a fixed message; the only part of a response body that reaches
|
|
53
|
+
this error, the logs, or the traceback chain is the allow-listed
|
|
54
|
+
problem+json hint, since an arbitrary body can quote secret material.
|
|
28
55
|
"""
|
|
29
56
|
|
|
30
|
-
def __init__(
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
message: str,
|
|
60
|
+
status: int | None = None,
|
|
61
|
+
problem: Mapping[str, object] | None = None,
|
|
62
|
+
) -> None:
|
|
31
63
|
self.status = status
|
|
32
|
-
|
|
64
|
+
with_status = message if status is None else f"{message} (status {status})"
|
|
65
|
+
hint = agent_auth_problem_hint(problem)
|
|
66
|
+
super().__init__(with_status if hint is None else f"{with_status}: {hint}")
|
|
33
67
|
|
|
34
68
|
|
|
35
69
|
__all__ = [
|
|
70
|
+
"AGENT_AUTH_PROBLEM_HINT_MAX_CHARS",
|
|
36
71
|
"AGENT_AUTH_TIMEOUT_SECONDS",
|
|
37
72
|
"CredentialStoreError",
|
|
38
73
|
"agent_auth_credential_query",
|
|
39
74
|
"agent_auth_headers",
|
|
75
|
+
"agent_auth_problem_hint",
|
|
40
76
|
"request_agent_auth_json",
|
|
41
77
|
]
|
|
42
78
|
|
|
@@ -70,16 +106,12 @@ def agent_auth_headers(
|
|
|
70
106
|
*,
|
|
71
107
|
api_key: str | None = None,
|
|
72
108
|
workspace_id: str | None = None,
|
|
73
|
-
service_key: str | None = None,
|
|
74
109
|
) -> dict[str, str]:
|
|
75
110
|
headers = {
|
|
76
111
|
"accept": "application/json",
|
|
77
112
|
"content-type": "application/json",
|
|
78
113
|
"x-langsmith-product": "mda",
|
|
79
114
|
}
|
|
80
|
-
resolved_service_key = (service_key or "").strip()
|
|
81
|
-
if resolved_service_key:
|
|
82
|
-
headers["x-service-key"] = resolved_service_key
|
|
83
115
|
resolved_api_key = (api_key or "").strip()
|
|
84
116
|
if resolved_api_key:
|
|
85
117
|
headers["x-api-key"] = resolved_api_key
|
|
Binary file
|
|
@@ -16,6 +16,12 @@ from managed_deepagents._channels.trigger.client import (
|
|
|
16
16
|
TriggerClientLike,
|
|
17
17
|
create_trigger_client,
|
|
18
18
|
)
|
|
19
|
+
from managed_deepagents._channels.trigger.identity_context import (
|
|
20
|
+
IdentityContextKey,
|
|
21
|
+
IdentityContextUnavailableError,
|
|
22
|
+
InvalidIdentityContextError,
|
|
23
|
+
principal_id_from_identity_context,
|
|
24
|
+
)
|
|
19
25
|
from managed_deepagents._channels.trigger.types import (
|
|
20
26
|
TRIGGER_SERVICE_KEY_HEADER,
|
|
21
27
|
TriggerChannelEvent,
|
|
@@ -52,6 +58,7 @@ class StartAgentFn(Protocol):
|
|
|
52
58
|
messages: list[dict[str, object]],
|
|
53
59
|
source_provider: str,
|
|
54
60
|
source_thread_key: str,
|
|
61
|
+
user_id: str,
|
|
55
62
|
) -> StartedIngressRun: ...
|
|
56
63
|
|
|
57
64
|
|
|
@@ -64,6 +71,7 @@ class ResumeAgentFn(Protocol):
|
|
|
64
71
|
resume: TriggerChannelResume,
|
|
65
72
|
source_provider: str,
|
|
66
73
|
source_thread_key: str,
|
|
74
|
+
user_id: str,
|
|
67
75
|
) -> StartedIngressRun: ...
|
|
68
76
|
|
|
69
77
|
|
|
@@ -74,6 +82,7 @@ async def _start_event_run(
|
|
|
74
82
|
source_provider: str,
|
|
75
83
|
start_agent: StartAgentFn | None,
|
|
76
84
|
resume_agent: ResumeAgentFn | None,
|
|
85
|
+
user_id: str,
|
|
77
86
|
) -> StartedIngressRun:
|
|
78
87
|
thread_id = _thread_id(event["source_thread_key"])
|
|
79
88
|
if event["type"] == "message":
|
|
@@ -84,6 +93,7 @@ async def _start_event_run(
|
|
|
84
93
|
messages=event["input"]["messages"],
|
|
85
94
|
source_provider=source_provider,
|
|
86
95
|
source_thread_key=event["source_thread_key"],
|
|
96
|
+
user_id=user_id,
|
|
87
97
|
)
|
|
88
98
|
resume = resume_agent or resume_ingress_agent
|
|
89
99
|
return await resume(
|
|
@@ -92,10 +102,11 @@ async def _start_event_run(
|
|
|
92
102
|
resume=event["interaction"]["resume"],
|
|
93
103
|
source_provider=source_provider,
|
|
94
104
|
source_thread_key=event["source_thread_key"],
|
|
105
|
+
user_id=user_id,
|
|
95
106
|
)
|
|
96
107
|
|
|
97
108
|
|
|
98
|
-
async def handle_trigger_channel_event_request(
|
|
109
|
+
async def handle_trigger_channel_event_request( # noqa: C901, PLR0911
|
|
99
110
|
request: object,
|
|
100
111
|
*,
|
|
101
112
|
source_provider: str,
|
|
@@ -106,9 +117,11 @@ async def handle_trigger_channel_event_request(
|
|
|
106
117
|
resume_agent: ResumeAgentFn | None = None,
|
|
107
118
|
observe_agent: ObserveAgentFn | None = None,
|
|
108
119
|
trigger_client: TriggerClientLike | None = None,
|
|
120
|
+
identity_context_key: IdentityContextKey | None = None,
|
|
121
|
+
identity_context_issuer: str | None = None,
|
|
109
122
|
) -> object:
|
|
110
123
|
"""Validate a Trigger message and enqueue its Agent Server run."""
|
|
111
|
-
# TODO: Validate
|
|
124
|
+
# TODO: Validate Trigger's platform-signed x-service-key JWT (not presence).
|
|
112
125
|
if not _nonblank_header(request, TRIGGER_SERVICE_KEY_HEADER):
|
|
113
126
|
return _json_response({"error": "unauthorized"}, status_code=401)
|
|
114
127
|
|
|
@@ -117,6 +130,32 @@ async def handle_trigger_channel_event_request(
|
|
|
117
130
|
except (TypeError, ValueError, UnicodeDecodeError):
|
|
118
131
|
logger.warning("invalid channel event")
|
|
119
132
|
return _json_response({"error": "invalid channel event"}, status_code=400)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
user_id = _principal_for_event(
|
|
136
|
+
event,
|
|
137
|
+
identity_context_key=identity_context_key,
|
|
138
|
+
identity_context_issuer=identity_context_issuer,
|
|
139
|
+
)
|
|
140
|
+
except _MissingIdentityContextError:
|
|
141
|
+
logger.warning("missing channel identity_context")
|
|
142
|
+
return _json_response({"error": "missing channel identity_context"}, status_code=400)
|
|
143
|
+
except IdentityContextUnavailableError as error:
|
|
144
|
+
logger.error(
|
|
145
|
+
"channel identity_context unavailable (%s)",
|
|
146
|
+
logger.safe_error_label(error),
|
|
147
|
+
)
|
|
148
|
+
return _json_response(
|
|
149
|
+
{"error": "identity_context verification unavailable"},
|
|
150
|
+
status_code=503,
|
|
151
|
+
)
|
|
152
|
+
except InvalidIdentityContextError as error:
|
|
153
|
+
logger.warning(
|
|
154
|
+
"invalid channel identity_context (%s)",
|
|
155
|
+
logger.safe_error_label(error),
|
|
156
|
+
)
|
|
157
|
+
return _json_response({"error": "invalid channel identity_context"}, status_code=400)
|
|
158
|
+
|
|
120
159
|
logger.info(
|
|
121
160
|
"Channel incoming event (type=%s, event_id=%s, delivery_id=%s, channel_id=%s)",
|
|
122
161
|
event["type"],
|
|
@@ -159,6 +198,7 @@ async def handle_trigger_channel_event_request(
|
|
|
159
198
|
source_provider=source_provider,
|
|
160
199
|
start_agent=start_agent,
|
|
161
200
|
resume_agent=resume_agent,
|
|
201
|
+
user_id=user_id,
|
|
162
202
|
)
|
|
163
203
|
except Exception as error: # noqa: BLE001 - never expose Agent Server details in the response
|
|
164
204
|
logger.error(
|
|
@@ -173,6 +213,9 @@ async def handle_trigger_channel_event_request(
|
|
|
173
213
|
started=started,
|
|
174
214
|
client=client,
|
|
175
215
|
observe_agent=observe_agent,
|
|
216
|
+
source_provider=source_provider,
|
|
217
|
+
source_thread_key=event["source_thread_key"],
|
|
218
|
+
user_id=user_id,
|
|
176
219
|
)
|
|
177
220
|
|
|
178
221
|
schedule_ingress_work(
|
|
@@ -192,6 +235,9 @@ async def _observe_accepted_run(
|
|
|
192
235
|
started: StartedIngressRun,
|
|
193
236
|
client: TriggerClientLike,
|
|
194
237
|
observe_agent: ObserveAgentFn | None,
|
|
238
|
+
source_provider: str | None = None,
|
|
239
|
+
source_thread_key: str | None = None,
|
|
240
|
+
user_id: str,
|
|
195
241
|
) -> None:
|
|
196
242
|
async def lifecycle(
|
|
197
243
|
event_type: TriggerChannelRuntimeEventType,
|
|
@@ -212,11 +258,13 @@ async def _observe_accepted_run(
|
|
|
212
258
|
)
|
|
213
259
|
|
|
214
260
|
await lifecycle("run.started")
|
|
215
|
-
observe = observe_agent or observe_ingress_agent
|
|
216
261
|
try:
|
|
217
|
-
observed = await
|
|
218
|
-
|
|
219
|
-
|
|
262
|
+
observed = await _observe_run(
|
|
263
|
+
started=started,
|
|
264
|
+
observe_agent=observe_agent,
|
|
265
|
+
source_provider=source_provider,
|
|
266
|
+
source_thread_key=source_thread_key,
|
|
267
|
+
user_id=user_id,
|
|
220
268
|
)
|
|
221
269
|
except Exception: # noqa: BLE001 - observation details stay internal
|
|
222
270
|
await lifecycle("run.failed")
|
|
@@ -263,6 +311,53 @@ async def _observe_accepted_run(
|
|
|
263
311
|
await lifecycle("run.completed")
|
|
264
312
|
|
|
265
313
|
|
|
314
|
+
async def _observe_run(
|
|
315
|
+
*,
|
|
316
|
+
started: StartedIngressRun,
|
|
317
|
+
observe_agent: ObserveAgentFn | None,
|
|
318
|
+
source_provider: str | None,
|
|
319
|
+
source_thread_key: str | None,
|
|
320
|
+
user_id: str,
|
|
321
|
+
) -> ObservedIngressRun:
|
|
322
|
+
if observe_agent is not None:
|
|
323
|
+
return await observe_agent(
|
|
324
|
+
**_observation_kwargs(
|
|
325
|
+
started=started,
|
|
326
|
+
source_provider=source_provider,
|
|
327
|
+
source_thread_key=source_thread_key,
|
|
328
|
+
user_id=user_id,
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
if source_provider is None or source_thread_key is None:
|
|
332
|
+
raise RuntimeError("channel observation context is not configured")
|
|
333
|
+
return await observe_ingress_agent(
|
|
334
|
+
thread_id=started["thread_id"],
|
|
335
|
+
run_id=started["run_id"],
|
|
336
|
+
source_provider=source_provider,
|
|
337
|
+
source_thread_key=source_thread_key,
|
|
338
|
+
user_id=user_id,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _observation_kwargs(
|
|
343
|
+
*,
|
|
344
|
+
started: StartedIngressRun,
|
|
345
|
+
source_provider: str | None,
|
|
346
|
+
source_thread_key: str | None,
|
|
347
|
+
user_id: str,
|
|
348
|
+
) -> dict[str, object]:
|
|
349
|
+
kwargs: dict[str, object] = {
|
|
350
|
+
"thread_id": started["thread_id"],
|
|
351
|
+
"run_id": started["run_id"],
|
|
352
|
+
"user_id": user_id,
|
|
353
|
+
}
|
|
354
|
+
if source_provider is None or source_thread_key is None:
|
|
355
|
+
return kwargs
|
|
356
|
+
kwargs["source_provider"] = source_provider
|
|
357
|
+
kwargs["source_thread_key"] = source_thread_key
|
|
358
|
+
return kwargs
|
|
359
|
+
|
|
360
|
+
|
|
266
361
|
def _is_interrupted_run(observed: ObservedIngressRun) -> bool:
|
|
267
362
|
# Agent Server can report status "success" after join while values still
|
|
268
363
|
# carry `__interrupt__`. Trust either signal.
|
|
@@ -315,6 +410,27 @@ def _thread_id(source_thread_key: str) -> str:
|
|
|
315
410
|
return str(uuid.uuid5(uuid.NAMESPACE_URL, source_thread_key))
|
|
316
411
|
|
|
317
412
|
|
|
413
|
+
class _MissingIdentityContextError(ValueError):
|
|
414
|
+
"""A message event arrived without a Trigger identity_context token."""
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _principal_for_event(
|
|
418
|
+
event: TriggerChannelEvent,
|
|
419
|
+
*,
|
|
420
|
+
identity_context_key: IdentityContextKey | None,
|
|
421
|
+
identity_context_issuer: str | None,
|
|
422
|
+
) -> str:
|
|
423
|
+
"""Resolve the verified principal required on every Trigger delivery."""
|
|
424
|
+
raw_identity_context = event.get("identity_context")
|
|
425
|
+
if not isinstance(raw_identity_context, str) or not raw_identity_context.strip():
|
|
426
|
+
raise _MissingIdentityContextError
|
|
427
|
+
return principal_id_from_identity_context(
|
|
428
|
+
raw_identity_context,
|
|
429
|
+
key=identity_context_key,
|
|
430
|
+
issuer=identity_context_issuer,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
318
434
|
def _resolve_assistant_id(override: str | None) -> str:
|
|
319
435
|
if override is not None and (explicit := override.strip()):
|
|
320
436
|
return explicit
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Verify Trigger Server ``identity_context`` JWTs into Agent Auth principal ids.
|
|
2
|
+
|
|
3
|
+
Agent Auth signs these with the LangSmith EdDSA JWKS published at
|
|
4
|
+
``{LANGSMITH_ENDPOINT}/.well-known/jwks.json``. MDA verifies the signature
|
|
5
|
+
(and typ/version/aud/iss/exp/principal UUID) before stamping ``x-mda-user-id``.
|
|
6
|
+
Delivery still requires Trigger's ``x-service-key`` header; signature verify is
|
|
7
|
+
what stops a forged principal when that gate is weak.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
import jwt
|
|
17
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
18
|
+
from jwt import PyJWK, PyJWKClient
|
|
19
|
+
|
|
20
|
+
IdentityContextKey = Ed25519PublicKey | PyJWK
|
|
21
|
+
|
|
22
|
+
IDENTITY_CONTEXT_TOKEN_TYP = "identity-context+jwt" # noqa: S105
|
|
23
|
+
IDENTITY_CONTEXT_AUDIENCE = "mda"
|
|
24
|
+
IDENTITY_CONTEXT_VERSION = "1"
|
|
25
|
+
IDENTITY_CONTEXT_ALGORITHMS = ("EdDSA",)
|
|
26
|
+
_DEFAULT_LANGSMITH_ENDPOINT = "https://api.smith.langchain.com"
|
|
27
|
+
_JWKS_HTTP_TIMEOUT_S = 5
|
|
28
|
+
|
|
29
|
+
_jwks_clients: dict[str, PyJWKClient] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InvalidIdentityContextError(ValueError):
|
|
33
|
+
"""Raised when a Trigger ``identity_context`` token is unusable."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class IdentityContextUnavailableError(RuntimeError):
|
|
37
|
+
"""JWKS / network failure while verifying identity_context. Callers should 5xx."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_identity_context_issuer(
|
|
41
|
+
env: Mapping[str, str] | None = None,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""LangSmith API origin used as identity-context ``iss`` and JWKS host."""
|
|
44
|
+
source = env if env is not None else os.environ
|
|
45
|
+
raw = (
|
|
46
|
+
(source.get("LANGSMITH_ENDPOINT") or "").strip()
|
|
47
|
+
or (source.get("LANGCHAIN_ENDPOINT") or "").strip()
|
|
48
|
+
or _DEFAULT_LANGSMITH_ENDPOINT
|
|
49
|
+
)
|
|
50
|
+
return raw.rstrip("/")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_identity_context_jwks_url(
|
|
54
|
+
env: Mapping[str, str] | None = None,
|
|
55
|
+
) -> str:
|
|
56
|
+
"""Public JWKS URL for Agent Auth identity-context signatures."""
|
|
57
|
+
return f"{resolve_identity_context_issuer(env)}/.well-known/jwks.json"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def principal_id_from_identity_context(
|
|
61
|
+
token: str,
|
|
62
|
+
*,
|
|
63
|
+
key: IdentityContextKey | None = None,
|
|
64
|
+
issuer: str | None = None,
|
|
65
|
+
env: Mapping[str, str] | None = None,
|
|
66
|
+
) -> str:
|
|
67
|
+
"""Extract the Agent Auth principal UUID from a Trigger identity-context JWT.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
InvalidIdentityContextError: when the token is missing, malformed, or
|
|
71
|
+
fails signature / claim checks.
|
|
72
|
+
"""
|
|
73
|
+
value = token.strip()
|
|
74
|
+
if not value:
|
|
75
|
+
raise InvalidIdentityContextError("identity_context is blank")
|
|
76
|
+
|
|
77
|
+
_require_identity_context_header(value)
|
|
78
|
+
claims = _verify_identity_context_claims(
|
|
79
|
+
value,
|
|
80
|
+
key=key,
|
|
81
|
+
issuer=issuer or resolve_identity_context_issuer(env),
|
|
82
|
+
env=env,
|
|
83
|
+
)
|
|
84
|
+
if claims.get("version") != IDENTITY_CONTEXT_VERSION:
|
|
85
|
+
raise InvalidIdentityContextError(
|
|
86
|
+
f"identity_context version must be {IDENTITY_CONTEXT_VERSION}"
|
|
87
|
+
)
|
|
88
|
+
return _principal_id_from_claims(claims)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def optional_principal_id_from_identity_context(
|
|
92
|
+
token: object | None,
|
|
93
|
+
*,
|
|
94
|
+
key: IdentityContextKey | None = None,
|
|
95
|
+
issuer: str | None = None,
|
|
96
|
+
env: Mapping[str, str] | None = None,
|
|
97
|
+
) -> str | None:
|
|
98
|
+
"""Resolve a principal id from an optional event field; ``None`` when absent."""
|
|
99
|
+
if token is None:
|
|
100
|
+
return None
|
|
101
|
+
if not isinstance(token, str):
|
|
102
|
+
raise InvalidIdentityContextError("identity_context must be a string")
|
|
103
|
+
if not token.strip():
|
|
104
|
+
return None
|
|
105
|
+
return principal_id_from_identity_context(token, key=key, issuer=issuer, env=env)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _require_identity_context_header(token: str) -> None:
|
|
109
|
+
try:
|
|
110
|
+
header = jwt.get_unverified_header(token)
|
|
111
|
+
except jwt.exceptions.DecodeError as error:
|
|
112
|
+
raise InvalidIdentityContextError("identity_context is not a JWT") from error
|
|
113
|
+
if header.get("typ") != IDENTITY_CONTEXT_TOKEN_TYP:
|
|
114
|
+
raise InvalidIdentityContextError(
|
|
115
|
+
f"identity_context typ must be {IDENTITY_CONTEXT_TOKEN_TYP}"
|
|
116
|
+
)
|
|
117
|
+
if header.get("alg") != "EdDSA":
|
|
118
|
+
raise InvalidIdentityContextError("identity_context alg must be EdDSA")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _verify_identity_context_claims(
|
|
122
|
+
token: str,
|
|
123
|
+
*,
|
|
124
|
+
key: IdentityContextKey | None,
|
|
125
|
+
issuer: str,
|
|
126
|
+
env: Mapping[str, str] | None,
|
|
127
|
+
) -> dict[str, object]:
|
|
128
|
+
algorithms = list(IDENTITY_CONTEXT_ALGORITHMS)
|
|
129
|
+
try:
|
|
130
|
+
verify_key = _jwks_client(env).get_signing_key_from_jwt(token) if key is None else key
|
|
131
|
+
decoded = jwt.decode(
|
|
132
|
+
token,
|
|
133
|
+
key=verify_key,
|
|
134
|
+
algorithms=algorithms,
|
|
135
|
+
audience=IDENTITY_CONTEXT_AUDIENCE,
|
|
136
|
+
issuer=issuer,
|
|
137
|
+
options={"require": ["exp"], "verify_exp": True},
|
|
138
|
+
)
|
|
139
|
+
except jwt.exceptions.ExpiredSignatureError as error:
|
|
140
|
+
raise InvalidIdentityContextError("identity_context is expired") from error
|
|
141
|
+
except jwt.exceptions.MissingRequiredClaimError as error:
|
|
142
|
+
raise InvalidIdentityContextError("identity_context is missing exp") from error
|
|
143
|
+
except jwt.exceptions.PyJWKClientConnectionError as error:
|
|
144
|
+
raise IdentityContextUnavailableError(
|
|
145
|
+
"identity_context JWKS verification is unavailable"
|
|
146
|
+
) from error
|
|
147
|
+
except TimeoutError as error:
|
|
148
|
+
raise IdentityContextUnavailableError(
|
|
149
|
+
"identity_context JWKS verification is unavailable"
|
|
150
|
+
) from error
|
|
151
|
+
except OSError as error:
|
|
152
|
+
raise IdentityContextUnavailableError(
|
|
153
|
+
"identity_context JWKS verification is unavailable"
|
|
154
|
+
) from error
|
|
155
|
+
except jwt.exceptions.PyJWTError as error:
|
|
156
|
+
raise InvalidIdentityContextError(
|
|
157
|
+
"identity_context signature verification failed"
|
|
158
|
+
) from error
|
|
159
|
+
if not isinstance(decoded, dict):
|
|
160
|
+
raise InvalidIdentityContextError("identity_context claims must be an object")
|
|
161
|
+
claims = {str(name): value for name, value in decoded.items()}
|
|
162
|
+
if not isinstance(claims.get("exp"), (int, float)) or isinstance(claims.get("exp"), bool):
|
|
163
|
+
raise InvalidIdentityContextError("identity_context is missing exp")
|
|
164
|
+
return claims
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _jwks_client(env: Mapping[str, str] | None) -> PyJWKClient:
|
|
168
|
+
uri = resolve_identity_context_jwks_url(env)
|
|
169
|
+
client = _jwks_clients.get(uri)
|
|
170
|
+
if client is None:
|
|
171
|
+
client = PyJWKClient(uri, timeout=_JWKS_HTTP_TIMEOUT_S)
|
|
172
|
+
_jwks_clients[uri] = client
|
|
173
|
+
return client
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _principal_id_from_claims(claims: Mapping[str, object]) -> str:
|
|
177
|
+
from_principal = _principal_id_claim(claims.get("principal"))
|
|
178
|
+
raw_subject = claims.get("sub")
|
|
179
|
+
from_subject = _normalize_uuid(raw_subject) if isinstance(raw_subject, str) else None
|
|
180
|
+
if from_principal and from_subject and from_principal != from_subject:
|
|
181
|
+
raise InvalidIdentityContextError("identity_context principal.id and sub disagree")
|
|
182
|
+
principal_id = from_principal or from_subject
|
|
183
|
+
if not principal_id:
|
|
184
|
+
raise InvalidIdentityContextError("identity_context is missing a principal UUID")
|
|
185
|
+
return principal_id
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _principal_id_claim(value: object) -> str | None:
|
|
189
|
+
if not isinstance(value, Mapping):
|
|
190
|
+
return None
|
|
191
|
+
raw_id = value.get("id")
|
|
192
|
+
return _normalize_uuid(raw_id) if isinstance(raw_id, str) else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _normalize_uuid(value: str) -> str | None:
|
|
196
|
+
try:
|
|
197
|
+
return str(UUID(value))
|
|
198
|
+
except ValueError:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
__all__ = [
|
|
203
|
+
"IDENTITY_CONTEXT_ALGORITHMS",
|
|
204
|
+
"IDENTITY_CONTEXT_AUDIENCE",
|
|
205
|
+
"IDENTITY_CONTEXT_TOKEN_TYP",
|
|
206
|
+
"IDENTITY_CONTEXT_VERSION",
|
|
207
|
+
"IdentityContextKey",
|
|
208
|
+
"IdentityContextUnavailableError",
|
|
209
|
+
"InvalidIdentityContextError",
|
|
210
|
+
"optional_principal_id_from_identity_context",
|
|
211
|
+
"principal_id_from_identity_context",
|
|
212
|
+
"resolve_identity_context_issuer",
|
|
213
|
+
"resolve_identity_context_jwks_url",
|
|
214
|
+
]
|
|
@@ -25,7 +25,7 @@ class TriggerChannelMessageInput(TypedDict):
|
|
|
25
25
|
messages: list[dict[str, object]]
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
class
|
|
28
|
+
class TriggerChannelMessageEventRequired(TypedDict):
|
|
29
29
|
version: Literal["1"]
|
|
30
30
|
type: Literal["message"]
|
|
31
31
|
event_id: str
|
|
@@ -37,6 +37,10 @@ class TriggerChannelMessageEvent(TypedDict):
|
|
|
37
37
|
input: TriggerChannelMessageInput
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
class TriggerChannelMessageEvent(TriggerChannelMessageEventRequired, total=False):
|
|
41
|
+
identity_context: str | None
|
|
42
|
+
|
|
43
|
+
|
|
40
44
|
# Keyed LangGraph resume: `{interrupt_id: value}`. Values are opaque
|
|
41
45
|
# (`interrupt()` results or HITL `{decisions}`).
|
|
42
46
|
TriggerChannelResume: TypeAlias = dict[str, object]
|
|
@@ -47,7 +51,7 @@ class TriggerChannelInterruptResumeInteraction(TypedDict):
|
|
|
47
51
|
resume: TriggerChannelResume
|
|
48
52
|
|
|
49
53
|
|
|
50
|
-
class
|
|
54
|
+
class TriggerChannelInterruptResumeEventRequired(TypedDict):
|
|
51
55
|
version: Literal["1"]
|
|
52
56
|
type: Literal["interrupt_resume"]
|
|
53
57
|
event_id: str
|
|
@@ -59,6 +63,10 @@ class TriggerChannelInterruptResumeEvent(TypedDict):
|
|
|
59
63
|
interaction: TriggerChannelInterruptResumeInteraction
|
|
60
64
|
|
|
61
65
|
|
|
66
|
+
class TriggerChannelInterruptResumeEvent(TriggerChannelInterruptResumeEventRequired, total=False):
|
|
67
|
+
identity_context: str | None
|
|
68
|
+
|
|
69
|
+
|
|
62
70
|
TriggerChannelEvent = TriggerChannelMessageEvent | TriggerChannelInterruptResumeEvent
|
|
63
71
|
|
|
64
72
|
|
|
@@ -170,6 +178,7 @@ class _MessageEventModel(_WireModel):
|
|
|
170
178
|
source_thread_key: _NonBlankString
|
|
171
179
|
address: SkipValidation[dict[str, object]]
|
|
172
180
|
input: _InputModel
|
|
181
|
+
identity_context: str | None = None
|
|
173
182
|
|
|
174
183
|
@field_validator("address", mode="before")
|
|
175
184
|
@classmethod
|
|
@@ -197,6 +206,7 @@ class _InterruptResumeEventModel(_WireModel):
|
|
|
197
206
|
source_thread_key: _NonBlankString
|
|
198
207
|
address: SkipValidation[dict[str, object]]
|
|
199
208
|
interaction: _InterruptResumeInteractionModel
|
|
209
|
+
identity_context: str | None = None
|
|
200
210
|
|
|
201
211
|
@field_validator("address", mode="before")
|
|
202
212
|
@classmethod
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""``connections.get(...)`` — read a managed connection from a tool.
|
|
2
2
|
|
|
3
|
-
``{ type: "agent" }`` resolves
|
|
3
|
+
``{ type: "agent" }`` resolves material owned by the current deployment.
|
|
4
4
|
``{ type: "user" }`` resolves per-caller grants (OAuth or opaque). Resolution
|
|
5
5
|
reads Agent Auth on every await and never caches.
|
|
6
6
|
|
|
@@ -157,11 +157,10 @@ class _AgentAuthConfig:
|
|
|
157
157
|
api_key: str
|
|
158
158
|
deployment_id: str | None
|
|
159
159
|
workspace_id: str
|
|
160
|
-
service_key: str | None = None
|
|
161
160
|
|
|
162
161
|
|
|
163
162
|
class AgentAuthConnectionStore:
|
|
164
|
-
"""Store backed by
|
|
163
|
+
"""Store backed by connection rows and deployment/user grants in Agent Auth."""
|
|
165
164
|
|
|
166
165
|
def __init__(
|
|
167
166
|
self,
|
|
@@ -188,7 +187,6 @@ class AgentAuthConnectionStore:
|
|
|
188
187
|
principal_id=owner_id,
|
|
189
188
|
api_key=config.api_key,
|
|
190
189
|
workspace_id=config.workspace_id,
|
|
191
|
-
service_key=config.service_key,
|
|
192
190
|
),
|
|
193
191
|
request=self._bind_request(config),
|
|
194
192
|
timeout=self._timeout,
|
|
@@ -215,7 +213,6 @@ class AgentAuthConnectionStore:
|
|
|
215
213
|
headers = agent_auth_headers(
|
|
216
214
|
api_key=config.api_key,
|
|
217
215
|
workspace_id=config.workspace_id,
|
|
218
|
-
service_key=config.service_key,
|
|
219
216
|
)
|
|
220
217
|
response = http_get(url, headers, timeout)
|
|
221
218
|
status = response.get("status")
|
|
@@ -254,19 +251,18 @@ def _lookup_workspace_connection(
|
|
|
254
251
|
if row is None:
|
|
255
252
|
return None
|
|
256
253
|
if owner["type"] == "user":
|
|
257
|
-
return _lookup_user_connection_material(client,
|
|
254
|
+
return _lookup_user_connection_material(client, row)
|
|
258
255
|
return _lookup_agent_connection_material(client, slug, row)
|
|
259
256
|
|
|
260
257
|
|
|
261
258
|
def _lookup_user_connection_material(
|
|
262
259
|
client: AgentAuth,
|
|
263
|
-
slug: str,
|
|
264
260
|
row: WorkspaceConnection,
|
|
265
261
|
) -> str | None:
|
|
266
262
|
if row.kind == "secret":
|
|
267
|
-
return client.
|
|
263
|
+
return client.read_user_secret_for_connection(row)
|
|
268
264
|
if row.kind == "oauth2":
|
|
269
|
-
return client.
|
|
265
|
+
return client.read_user_access_token_for_connection(row)
|
|
270
266
|
return None
|
|
271
267
|
|
|
272
268
|
|
|
@@ -429,7 +425,6 @@ def resolve_agent_auth_context(
|
|
|
429
425
|
principal_id=resolve_owner_id({"type": "user"}, identity, config.deployment_id),
|
|
430
426
|
api_key=config.api_key,
|
|
431
427
|
workspace_id=config.workspace_id,
|
|
432
|
-
service_key=config.service_key,
|
|
433
428
|
)
|
|
434
429
|
|
|
435
430
|
|
|
@@ -443,13 +438,11 @@ def _resolve_agent_auth_config(env: Mapping[str, str] | None) -> _AgentAuthConfi
|
|
|
443
438
|
raise CredentialStoreError(
|
|
444
439
|
f"connections are not configured: {_WORKSPACE_ID_ENV} is not set"
|
|
445
440
|
)
|
|
446
|
-
service_key = (resolved.get("MDA_AGENT_AUTH_SERVICE_KEY") or "").strip() or None
|
|
447
441
|
return _AgentAuthConfig(
|
|
448
442
|
base_url=resolve_agent_auth_base_url(resolved),
|
|
449
443
|
api_key=api_key,
|
|
450
444
|
deployment_id=(resolved.get(DEPLOYMENT_ID_ENV) or "").strip() or None,
|
|
451
445
|
workspace_id=workspace_id,
|
|
452
|
-
service_key=service_key,
|
|
453
446
|
)
|
|
454
447
|
|
|
455
448
|
|
|
@@ -190,9 +190,9 @@ def _read_user_connection_grants(
|
|
|
190
190
|
raise CredentialStoreError(f"workspace connection {slug!r} is not configured")
|
|
191
191
|
kind: Literal["oauth2", "secret"] = "secret" if row.kind == "secret" else "oauth2"
|
|
192
192
|
token = (
|
|
193
|
-
agent_auth.
|
|
193
|
+
agent_auth.read_user_secret_for_connection(row)
|
|
194
194
|
if kind == "secret"
|
|
195
|
-
else agent_auth.
|
|
195
|
+
else agent_auth.read_user_access_token_for_connection(row)
|
|
196
196
|
)
|
|
197
197
|
grants.append(_UserConnectionGrant(slug=slug, kind=kind, token=token))
|
|
198
198
|
return grants
|
|
@@ -124,6 +124,9 @@ CONNECTOR_EVENTS_PATH_SUFFIX = "/events"
|
|
|
124
124
|
CHANNEL_EVENTS_USER = "mda:channel-events"
|
|
125
125
|
CHANNEL_SERVICE_USER = "mda:channel"
|
|
126
126
|
|
|
127
|
+
#: Owner-key namespace for threads owned by a channel source conversation.
|
|
128
|
+
CHANNEL_THREAD_OWNER_PREFIX = "mda:source-thread:"
|
|
129
|
+
|
|
127
130
|
_BUILTIN_CHANNEL_SOURCE_PROVIDERS = frozenset({"slack", "github"})
|
|
128
131
|
|
|
129
132
|
|
|
@@ -203,16 +206,20 @@ def build_managed_auth(identity: IdentityDefinitionLike) -> AuthInstance:
|
|
|
203
206
|
return await _authenticate(cfg, http, auth_cls, headers, authorization, path, method)
|
|
204
207
|
|
|
205
208
|
async def scope_threads(ctx: object, value: object) -> Mapping[str, str] | None:
|
|
206
|
-
# Studio
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
# scheme, so local Studio arrives as the local-dev service principal and
|
|
212
|
-
# stays unscoped through the check below.
|
|
209
|
+
# Let Studio inspect every deployment thread so channel-triggered runs
|
|
210
|
+
# and their interrupts are visible there. Keep mutations owner-scoped:
|
|
211
|
+
# Studio identity is reachable by any holder of a workspace LangSmith
|
|
212
|
+
# API key, so exempting all actions would let that key alter caller
|
|
213
|
+
# threads too.
|
|
213
214
|
user = getattr(ctx, "user", ctx)
|
|
214
215
|
if is_local_dev_unscoped_user(user):
|
|
215
216
|
return None
|
|
217
|
+
if (
|
|
218
|
+
is_studio_user is not None
|
|
219
|
+
and is_studio_user(user)
|
|
220
|
+
and getattr(ctx, "action", "") in {"read", "search"}
|
|
221
|
+
):
|
|
222
|
+
return None
|
|
216
223
|
key = _thread_owner_key(user)
|
|
217
224
|
if isinstance(value, dict):
|
|
218
225
|
payload = cast("JsonObject", value)
|
|
@@ -660,15 +667,50 @@ def _has_valid_ingress_secret(headers: Mapping[str, object]) -> bool:
|
|
|
660
667
|
def _thread_owner_key(user: object) -> str:
|
|
661
668
|
"""Derive the owner value ``@auth.on.threads`` filters on.
|
|
662
669
|
|
|
670
|
+
A managed channel thread is owned by its source conversation; every other
|
|
671
|
+
caller owns its threads by user id. Channel threads are addressed by
|
|
672
|
+
``source_thread_key`` and the deliveries that reach one can legitimately
|
|
673
|
+
carry different principals: the Trigger Server does not attach
|
|
674
|
+
``identity_context`` to every envelope, and any participant may reply in a
|
|
675
|
+
shared thread or answer an interrupt someone else raised. Keying such a
|
|
676
|
+
thread on the delivering principal denies every later delivery that does
|
|
677
|
+
not match.
|
|
678
|
+
|
|
679
|
+
Only managed ingress sets both source stamps, and only over a trusted
|
|
680
|
+
loopback credential — a client presenting them is rejected in
|
|
681
|
+
:func:`_reject_untrusted_handoff_headers`. ``mda_user_id`` still carries the
|
|
682
|
+
principal, so credential resolution and store scoping stay per caller.
|
|
683
|
+
|
|
663
684
|
Principals resolved by ``authenticate`` always carry ``mda_user_id``. A
|
|
664
685
|
Studio user does not: the Agent Server builds it for
|
|
665
686
|
``x-auth-scheme: langsmith`` without consulting managed auth, so it exposes
|
|
666
687
|
only ``identity``. Fall back to that rather than bucketing every such caller
|
|
667
688
|
under an empty owner.
|
|
668
689
|
"""
|
|
690
|
+
conversation = _channel_conversation_owner_key(user)
|
|
691
|
+
if conversation is not None:
|
|
692
|
+
return conversation
|
|
669
693
|
return str(_field(user, "mda_user_id") or _field(user, "identity") or "")
|
|
670
694
|
|
|
671
695
|
|
|
696
|
+
def _channel_conversation_owner_key(user: object) -> str | None:
|
|
697
|
+
"""Owner key for a channel ingress caller, or ``None`` for anyone else.
|
|
698
|
+
|
|
699
|
+
The prefix keeps conversation owners in a namespace no ``mda_user_id`` can
|
|
700
|
+
reach (those are UUIDs, emails, or ``mda:*`` service ids), so a source
|
|
701
|
+
thread key can never collide with a caller.
|
|
702
|
+
"""
|
|
703
|
+
provider = _trimmed_stamp(_field(user, "mda_source_provider"))
|
|
704
|
+
thread_key = _trimmed_stamp(_field(user, "mda_source_thread_id"))
|
|
705
|
+
if not provider or not thread_key:
|
|
706
|
+
return None
|
|
707
|
+
return f"{CHANNEL_THREAD_OWNER_PREFIX}{provider}:{thread_key}"
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _trimmed_stamp(value: object) -> str:
|
|
711
|
+
return value.strip() if isinstance(value, str) else ""
|
|
712
|
+
|
|
713
|
+
|
|
672
714
|
#: LangGraph Store namespace prefix for Slack ↔ actor account links.
|
|
673
715
|
IDENTITY_LINKS_STORE_PREFIX = "mda_identity_links"
|
|
674
716
|
|
managed_deepagents/_ingress.py
CHANGED
|
@@ -19,7 +19,6 @@ from managed_deepagents._channels.assistant_text import extract_assistant_text
|
|
|
19
19
|
from managed_deepagents._channels.binding import CHANNEL_BINDING_KEY, ChannelBinding
|
|
20
20
|
from managed_deepagents._channels.types import ChannelError
|
|
21
21
|
from managed_deepagents._identity_auth import (
|
|
22
|
-
CHANNEL_SERVICE_USER,
|
|
23
22
|
HANDOFF_SECRET_ENV,
|
|
24
23
|
HANDOFF_SECRET_HEADER,
|
|
25
24
|
INGRESS_SECRET_ENV,
|
|
@@ -73,6 +72,7 @@ class ObservedIngressRun(TypedDict):
|
|
|
73
72
|
class _IngressSource(TypedDict):
|
|
74
73
|
provider: str
|
|
75
74
|
thread_key: str
|
|
75
|
+
user_id: str
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
class _ThreadsClient(Protocol):
|
|
@@ -165,8 +165,8 @@ async def ingress_source_thread_exists(
|
|
|
165
165
|
) -> bool:
|
|
166
166
|
"""True when an MDA thread already exists for this source-thread key.
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
the
|
|
168
|
+
The thread is owned by the source conversation rather than by the caller, so
|
|
169
|
+
this answers "is the agent already in this thread" for any participant.
|
|
170
170
|
"""
|
|
171
171
|
if not _has_loopback_credential():
|
|
172
172
|
return False
|
|
@@ -236,10 +236,18 @@ async def start_ingress_agent(
|
|
|
236
236
|
messages: list[dict[str, object]],
|
|
237
237
|
source_provider: str,
|
|
238
238
|
source_thread_key: str,
|
|
239
|
+
user_id: str,
|
|
239
240
|
) -> StartedIngressRun:
|
|
240
|
-
"""Create a Trigger-addressed thread and enqueue its run.
|
|
241
|
+
"""Create a Trigger-addressed thread and enqueue its run.
|
|
242
|
+
|
|
243
|
+
``user_id`` is the Agent Auth principal UUID from Trigger ``identity_context``.
|
|
244
|
+
"""
|
|
241
245
|
client = _create_trigger_ingress_loopback_client(
|
|
242
|
-
{
|
|
246
|
+
{
|
|
247
|
+
"provider": source_provider,
|
|
248
|
+
"thread_key": source_thread_key,
|
|
249
|
+
"user_id": user_id,
|
|
250
|
+
}
|
|
243
251
|
)
|
|
244
252
|
await client.threads.create(
|
|
245
253
|
thread_id=thread_id,
|
|
@@ -268,10 +276,18 @@ async def resume_ingress_agent(
|
|
|
268
276
|
resume: dict[str, object],
|
|
269
277
|
source_provider: str,
|
|
270
278
|
source_thread_key: str,
|
|
279
|
+
user_id: str,
|
|
271
280
|
) -> StartedIngressRun:
|
|
272
|
-
"""Resume a Trigger-addressed thread with a keyed interrupt response.
|
|
281
|
+
"""Resume a Trigger-addressed thread with a keyed interrupt response.
|
|
282
|
+
|
|
283
|
+
``user_id`` is the Agent Auth principal UUID from Trigger ``identity_context``.
|
|
284
|
+
"""
|
|
273
285
|
client = _create_trigger_ingress_loopback_client(
|
|
274
|
-
{
|
|
286
|
+
{
|
|
287
|
+
"provider": source_provider,
|
|
288
|
+
"thread_key": source_thread_key,
|
|
289
|
+
"user_id": user_id,
|
|
290
|
+
}
|
|
275
291
|
)
|
|
276
292
|
run = await client.runs.create(
|
|
277
293
|
thread_id,
|
|
@@ -292,9 +308,18 @@ async def observe_ingress_agent(
|
|
|
292
308
|
*,
|
|
293
309
|
thread_id: str,
|
|
294
310
|
run_id: str,
|
|
311
|
+
source_provider: str,
|
|
312
|
+
source_thread_key: str,
|
|
313
|
+
user_id: str,
|
|
295
314
|
) -> ObservedIngressRun:
|
|
296
|
-
"""Join an accepted run
|
|
297
|
-
client = _create_trigger_ingress_loopback_client(
|
|
315
|
+
"""Join an accepted run as the principal that owns its thread."""
|
|
316
|
+
client = _create_trigger_ingress_loopback_client(
|
|
317
|
+
{
|
|
318
|
+
"provider": source_provider,
|
|
319
|
+
"thread_key": source_thread_key,
|
|
320
|
+
"user_id": user_id,
|
|
321
|
+
}
|
|
322
|
+
)
|
|
298
323
|
values = await client.runs.join(thread_id, run_id)
|
|
299
324
|
run = await client.runs.get(thread_id, run_id)
|
|
300
325
|
status = run.get("status") if isinstance(run, Mapping) else None
|
|
@@ -312,7 +337,7 @@ async def observe_ingress_agent(
|
|
|
312
337
|
|
|
313
338
|
|
|
314
339
|
def _create_trigger_ingress_loopback_client(
|
|
315
|
-
source: _IngressSource
|
|
340
|
+
source: _IngressSource,
|
|
316
341
|
) -> _IngressLoopbackClient:
|
|
317
342
|
api_key = os.environ.get(LANGCHAIN_API_KEY_ENV, "").strip()
|
|
318
343
|
if not api_key:
|
|
@@ -322,10 +347,17 @@ def _create_trigger_ingress_loopback_client(
|
|
|
322
347
|
)
|
|
323
348
|
module = import_module("langgraph_sdk")
|
|
324
349
|
get_client = cast("_GetClient", module.get_client)
|
|
325
|
-
|
|
326
|
-
if
|
|
327
|
-
|
|
328
|
-
|
|
350
|
+
user_id = source["user_id"].strip()
|
|
351
|
+
if not user_id:
|
|
352
|
+
raise ChannelError(
|
|
353
|
+
"channel_run_failed",
|
|
354
|
+
"channel run requires an identity_context principal",
|
|
355
|
+
)
|
|
356
|
+
headers = {
|
|
357
|
+
USER_HEADER: user_id,
|
|
358
|
+
SOURCE_PROVIDER_HEADER: source["provider"],
|
|
359
|
+
SOURCE_THREAD_HEADER: source["thread_key"],
|
|
360
|
+
}
|
|
329
361
|
return get_client(
|
|
330
362
|
url=resolve_loopback_api_url(),
|
|
331
363
|
api_key=api_key,
|
{managed_deepagents-0.6.2.dev37.dist-info → managed_deepagents-0.6.2.dev38.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: managed-deepagents
|
|
3
|
-
Version: 0.6.2.
|
|
3
|
+
Version: 0.6.2.dev38
|
|
4
4
|
Summary: Managed Deep Agents — the define_deep_agent authoring interface plus the CLI that compiles and deploys a code-first Deep Agent repository to a managed LangGraph runtime.
|
|
5
5
|
Author: LangChain
|
|
6
6
|
License: MIT
|
|
@@ -282,7 +282,9 @@ when a grant is missing. `mda dev` has no deployment to hold user grants, so
|
|
|
282
282
|
a user-owned connection fails there with an actionable error; deploy the agent
|
|
283
283
|
to exercise one.
|
|
284
284
|
|
|
285
|
-
|
|
285
|
+
Deploy the project first, then provision its connections with
|
|
286
|
+
`mda connections create`. Agent-owned opaque secrets are scoped to that
|
|
287
|
+
deployment, so the command refuses to create one before `mda deploy`.
|
|
286
288
|
`mda connections check` verifies manifest slugs exist in the workspace.
|
|
287
289
|
|
|
288
290
|
Use `include_tools` or `exclude_tools` inside a server config to select a
|
{managed_deepagents-0.6.2.dev37.dist-info → managed_deepagents-0.6.2.dev38.dist-info}/RECORD
RENAMED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
managed_deepagents/__init__.py,sha256=RezkpgYxCcywj6_W7Nk3PN9TMBfYyFgIatxGrIeY8V4,1862
|
|
2
2
|
managed_deepagents/__main__.py,sha256=GcjmqN0Ea5v8OHKMxYToasm3eCX_doSLOlpEzAi1tAw,67
|
|
3
|
-
managed_deepagents/_agent_auth.py,sha256=
|
|
4
|
-
managed_deepagents/_agent_auth_http.py,sha256=
|
|
3
|
+
managed_deepagents/_agent_auth.py,sha256=bOxTT1ZX9BZZyvgIaxd9SZycbEaQl-HuCrPieCAv6G0,17258
|
|
4
|
+
managed_deepagents/_agent_auth_http.py,sha256=aCYn7Rrtw3cnK3r4ir4JWxwzFkb7cF7nBgPpNdydxDw,5108
|
|
5
5
|
managed_deepagents/_channel.py,sha256=-Og7QOM1yvV-yeTDOfv2CxXpR2HLY4xdX1bLrIsPi5k,5272
|
|
6
6
|
managed_deepagents/_cli.py,sha256=jVaw2i1ysdRoWziMdPFmRXtKry_baYGRspyzyIeGWoU,1688
|
|
7
|
-
managed_deepagents/_connections.py,sha256=
|
|
7
|
+
managed_deepagents/_connections.py,sha256=VwVBwM4T0rp1MgYu9D8QjGj1ireYeEuP3VIAFN5urFY,16246
|
|
8
8
|
managed_deepagents/_connector.py,sha256=Syp0SRs1oFCUUltIUmZbSNi1Ys_PxObVdiAi-I9GBzs,8040
|
|
9
9
|
managed_deepagents/_cors.py,sha256=V96RPrqewdRWG5RpEMEBnPuRq9_zF8nf4vf99k5dkuI,7597
|
|
10
|
-
managed_deepagents/_credential_gate.py,sha256=
|
|
10
|
+
managed_deepagents/_credential_gate.py,sha256=SNxXunqmHgjBsZpiZu8yp49YkRIO0amkl0-7GHLGnIg,12280
|
|
11
11
|
managed_deepagents/_eval_filesystem.py,sha256=1nuD0yn3gh19JrWNocWyPFyeJW42E7Gse3XRWCUSTAA,1399
|
|
12
12
|
managed_deepagents/_extension.py,sha256=x2H1S8inkvTZQ_dr7kq0M8o8rre1FjzCDvMzItw4iyk,5318
|
|
13
|
-
managed_deepagents/_identity_auth.py,sha256=
|
|
13
|
+
managed_deepagents/_identity_auth.py,sha256=l0t0Xx-J1-z_Zw4K5D-B2yV6lNdeJgOw7UvxODxUxZQ,32798
|
|
14
14
|
managed_deepagents/_identity_http.py,sha256=aVQfZr7UKQoXF1Dry3p9fzXIj1882sEa668rvhn37i0,16029
|
|
15
15
|
managed_deepagents/_identity_links.py,sha256=Z1H2Seo2KaI-vcdU3_PGM-F06wOz0IQqUP-28wqiQsA,9172
|
|
16
16
|
managed_deepagents/_identity_runtime.py,sha256=vsshj0TNWd-rXUhVsPAqXIkANunQT7jI94CWgzq-1cY,24409
|
|
17
|
-
managed_deepagents/_ingress.py,sha256=
|
|
17
|
+
managed_deepagents/_ingress.py,sha256=iqsGm2jZKKn0FqjNn2nJAlR7d-IqCpVKWo6NbMEhjxw,12323
|
|
18
18
|
managed_deepagents/_log.py,sha256=oYwOO0ZL4-INkge9YJvtLuCxL9zAtgjSPIggVbkcyc8,2932
|
|
19
19
|
managed_deepagents/_loopback.py,sha256=hQ1oNi77XpdUjzZURSBRoMvJ1mGCs-STobfBftGmXuE,2409
|
|
20
20
|
managed_deepagents/_managed_middleware.py,sha256=cfInVHTVjWoy_DBoFlnslfrDf2_n6llGnTGasta4AIo,10295
|
|
@@ -28,7 +28,7 @@ managed_deepagents/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
|
28
28
|
managed_deepagents/runtime.py,sha256=B4iKTJZD0zXx_MZ09pqQFOkVo07tqJDT3qvOBr-0iOk,65782
|
|
29
29
|
managed_deepagents/sandbox.py,sha256=xlpcWa6CoVX6GKip2nK-0Lw-HBCoFGFYm0r4x9eYwcY,2530
|
|
30
30
|
managed_deepagents/schedules.py,sha256=MtTQN4tlm1NizFDCjZZv_huGfbQx-dj9WP1dIBybCug,5237
|
|
31
|
-
managed_deepagents/_binaries/mda.exe,sha256=
|
|
31
|
+
managed_deepagents/_binaries/mda.exe,sha256=YIpbc2KG4LIR0P3gydFMZPJAOvPZ2TbAhRWwePefqNo,3855360
|
|
32
32
|
managed_deepagents/_channels/__init__.py,sha256=Z6eXmzaLxG09DSyJoY_CX34axcsBo9gSJ0G7pRm2D6M,1216
|
|
33
33
|
managed_deepagents/_channels/assistant_text.py,sha256=f-Z3J3PlQFXZHztgBxlRoqB2LigU1Hj1YpwoXBMTR18,2171
|
|
34
34
|
managed_deepagents/_channels/binding.py,sha256=6vAKbWpg-eyLErJ0SsozH4mZc50hA1NhquM63mVmWDQ,3423
|
|
@@ -50,8 +50,9 @@ managed_deepagents/_channels/slack/transport.py,sha256=_xQmzK2hBpPOoY3zKFhzc3vvl
|
|
|
50
50
|
managed_deepagents/_channels/trigger/__init__.py,sha256=bWj66XoPbDdrztPUCKBuSJBBS2pBocn4EA8FDE3WeBA,48
|
|
51
51
|
managed_deepagents/_channels/trigger/channel.py,sha256=rKSvOABOZX58ZIvZ3hKcYxvTx9npQ0YXPQWk9JEy6XE,5108
|
|
52
52
|
managed_deepagents/_channels/trigger/client.py,sha256=cM97gzYWUTFC9o3uoveFe9-HxZc6_VqiqC8u4fKqjXc,8572
|
|
53
|
-
managed_deepagents/_channels/trigger/http.py,sha256=
|
|
54
|
-
managed_deepagents/_channels/trigger/
|
|
53
|
+
managed_deepagents/_channels/trigger/http.py,sha256=O8yMleioXj3s5QXj7jK-HpUpiSFppjfRjHJ2tdfX0Is,16205
|
|
54
|
+
managed_deepagents/_channels/trigger/identity_context.py,sha256=oo6sgtNDLGGkZCzrkrifMHkkAqxj7ui2HgUUdjNA1iU,7701
|
|
55
|
+
managed_deepagents/_channels/trigger/types.py,sha256=42JMOFXP9MfqwQ66SFlAiwC7q_AnNIJXBts3sAujxac,6625
|
|
55
56
|
managed_deepagents/_connectors/__init__.py,sha256=c_SdH3T7T6bgX7-I5jxAqDzHojmqVUCX0AXRI7g5Ovs,575
|
|
56
57
|
managed_deepagents/_connectors/tool_selection.py,sha256=xMvohEvg_rguQvziUXzxKn4V140mmearZHel97F8tVs,2978
|
|
57
58
|
managed_deepagents/_connectors/mcp/__init__.py,sha256=3AL429g5djYEYuiuSqsxj2VESpTkyU2NeBIJsHlWVk4,11162
|
|
@@ -68,9 +69,9 @@ managed_deepagents/identity/__init__.py,sha256=Slm-oEqKxbaZEQsMqHfD3LbG5M4vH2lKU
|
|
|
68
69
|
managed_deepagents/identity/claims.py,sha256=prby7r_8Lww3MmoBm8PX6PsSGE3GwSHkr8mVIz_08nU,1180
|
|
69
70
|
managed_deepagents/identity/options.py,sha256=x_yMbXEFR7zXyehEjDHG2qjAlukXjrRBcQLcM6IndLM,2550
|
|
70
71
|
managed_deepagents/identity/supabase.py,sha256=8uobl3WCrQL_ieSXG8KTLf1th7Qjim9liZT50HeaJE0,6026
|
|
71
|
-
managed_deepagents-0.6.2.
|
|
72
|
-
managed_deepagents-0.6.2.
|
|
73
|
-
managed_deepagents-0.6.2.
|
|
74
|
-
managed_deepagents-0.6.2.
|
|
75
|
-
managed_deepagents-0.6.2.
|
|
76
|
-
managed_deepagents-0.6.2.
|
|
72
|
+
managed_deepagents-0.6.2.dev38.dist-info/licenses/LICENSE,sha256=WnPZQQ608ojZUkXl7BW6VbKloIAibZiGm1K6cjeZOL8,1066
|
|
73
|
+
managed_deepagents-0.6.2.dev38.dist-info/METADATA,sha256=4MchIfDGgZpDSjhaeAv3ZFKxdTA6PsHARKf-Vv7ddNo,13709
|
|
74
|
+
managed_deepagents-0.6.2.dev38.dist-info/WHEEL,sha256=3I5VVWZdsFlU417aCS2bMRuerS8wsfvBq99MyatBV3A,97
|
|
75
|
+
managed_deepagents-0.6.2.dev38.dist-info/entry_points.txt,sha256=ziZqkKuaDO3qHCjKmBhrx9cm3ok8bZ9xcEDpUTj4unM,53
|
|
76
|
+
managed_deepagents-0.6.2.dev38.dist-info/top_level.txt,sha256=fdBo4O0Hlol_w022tTLB5ftZpamb4yJoQ3MUvjgTGfA,19
|
|
77
|
+
managed_deepagents-0.6.2.dev38.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{managed_deepagents-0.6.2.dev37.dist-info → managed_deepagents-0.6.2.dev38.dist-info}/top_level.txt
RENAMED
|
File without changes
|