managed-deepagents 0.6.2.dev5__py3-none-win_amd64.whl → 0.6.2.dev7__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.
@@ -9,12 +9,19 @@ This package provides two things:
9
9
 
10
10
  Resources use ``define_*`` factories while provider collections use namespaces:
11
11
  ``define_sandbox()``, ``channels.slack(...)``, ``connectors.mcp(...)``,
12
- and ``auth.supabase(...)``. Contributing a *new*
12
+ ``credentials.get(...)``, and ``auth.supabase(...)``. Contributing a *new*
13
13
  primitive is not part of the public surface.
14
14
  """
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ from managed_deepagents._credentials import (
19
+ CredentialAuthorizationRequiredError,
20
+ CredentialNotFoundError,
21
+ CredentialOwner,
22
+ CredentialStoreError,
23
+ credentials,
24
+ )
18
25
  from managed_deepagents._managed_tools import (
19
26
  ManagedDeepAgentRuntime,
20
27
  )
@@ -31,6 +38,10 @@ from managed_deepagents.sandbox import SandboxDefinition
31
38
  from managed_deepagents.schedules import define_schedule
32
39
 
33
40
  __all__ = [
41
+ "CredentialAuthorizationRequiredError",
42
+ "CredentialNotFoundError",
43
+ "CredentialOwner",
44
+ "CredentialStoreError",
34
45
  "DeepAgentDefinition",
35
46
  "ManagedDeepAgentRuntime",
36
47
  "MemoryDefinition",
@@ -38,6 +49,7 @@ __all__ = [
38
49
  "auth",
39
50
  "channels",
40
51
  "connectors",
52
+ "credentials",
41
53
  "define_deep_agent",
42
54
  "define_identity",
43
55
  "define_memory",
@@ -0,0 +1,260 @@
1
+ """Agent Auth client used by ``credentials.get`` and the pre-run credential gate.
2
+
3
+ Talks to the shipped Agent Auth APIs (credentials + oauth-authorization-sessions),
4
+ not the aspirational ``credentials/mint`` endpoint.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import urllib.parse
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from typing import Protocol, TypedDict
13
+
14
+ from managed_deepagents._agent_auth_http import (
15
+ AGENT_AUTH_TIMEOUT_SECONDS,
16
+ CredentialStoreError,
17
+ agent_auth_credential_query,
18
+ agent_auth_headers,
19
+ request_agent_auth_json,
20
+ )
21
+
22
+ AGENT_AUTH_PROVIDER_PAGE_SIZE = 100
23
+ AGENT_AUTH_MAX_PROVIDER_PAGES = 20
24
+
25
+ _HTTP_BAD_REQUEST = 400
26
+ _HTTP_NOT_FOUND = 404
27
+ _HTTP_CONFLICT = 409
28
+
29
+ __all__ = [
30
+ "AGENT_AUTH_TIMEOUT_SECONDS",
31
+ "AgentAuth",
32
+ "AgentAuthContext",
33
+ "AgentAuthUnknownProviderError",
34
+ "CredentialAuthorizationRequiredError",
35
+ "PendingAuthorization",
36
+ ]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class AgentAuthContext:
41
+ base_url: str
42
+ #: Deployment id, passed to oauth-authorization-sessions as ``agent_id``.
43
+ agent_id: str
44
+ #: Authenticated caller, used as ``owner_id`` for user credentials.
45
+ principal_id: str
46
+ api_key: str | None = None
47
+ workspace_id: str | None = None
48
+
49
+
50
+ class PendingAuthorization(TypedDict):
51
+ """One credential slug the caller has not granted, with its connect URL."""
52
+
53
+ slug: str
54
+ connect_url: str
55
+ auth_id: str
56
+
57
+
58
+ class CredentialAuthorizationRequiredError(RuntimeError):
59
+ def __init__(self, slugs: list[str]) -> None:
60
+ super().__init__(
61
+ f"the caller has not connected: {', '.join(slugs)}. Declare the credential "
62
+ "on a connector so the pre-run gate can request it."
63
+ )
64
+ self.slugs = slugs
65
+
66
+
67
+ class AgentAuthUnknownProviderError(RuntimeError):
68
+ def __init__(self, slug: str) -> None:
69
+ super().__init__(
70
+ f'Agent Auth has no OAuth provider registered for slug "{slug}". '
71
+ "Register the provider or fix the credential slug."
72
+ )
73
+ self.slug = slug
74
+
75
+
76
+ class AgentAuthRequest(Protocol):
77
+ """HTTP surface bound onto :class:`AgentAuth` (method, path, optional JSON body)."""
78
+
79
+ def __call__(
80
+ self,
81
+ method: str,
82
+ path: str,
83
+ *,
84
+ body: dict[str, object] | None = None,
85
+ ) -> tuple[int, dict[str, object]]: ...
86
+
87
+
88
+ class AgentAuth:
89
+ """Bound Agent Auth HTTP client. Headers, origin, and timeout live on the instance."""
90
+
91
+ def __init__(
92
+ self,
93
+ context: AgentAuthContext,
94
+ *,
95
+ request: AgentAuthRequest | None = None,
96
+ timeout: float = AGENT_AUTH_TIMEOUT_SECONDS,
97
+ ) -> None:
98
+ """Bind this client to a caller.
99
+
100
+ Args:
101
+ context: Caller/run identity and Agent Auth origin.
102
+ request: Testing only. Substitute for the default HTTP transport.
103
+ timeout: Testing only. Override the request budget in seconds.
104
+ """
105
+ self.context = context
106
+ self._timeout = timeout
107
+ self._request: AgentAuthRequest = request if request is not None else self._urlopen_json
108
+
109
+ def read_user_access_token(self, slug: str) -> str | None:
110
+ """Read the caller's OAuth access token without starting a session.
111
+
112
+ Reads never mint, so a caller can be probed repeatedly — on every replay
113
+ of an interrupted node, for instance — without leaving authorization
114
+ sessions behind.
115
+
116
+ Returns:
117
+ The access token, or ``None`` when the caller must connect.
118
+ """
119
+ credential_id = self.find_credential_id(
120
+ owner_type="user",
121
+ owner_id=self.context.principal_id,
122
+ kind="oauth2",
123
+ slug=slug,
124
+ )
125
+ if credential_id is None:
126
+ return None
127
+ return self._fetch_access_token(credential_id)
128
+
129
+ def start_authorization_session(self, slug: str) -> PendingAuthorization:
130
+ """Start an OAuth session for a slug the caller has not granted."""
131
+ provider_id = self.resolve_oauth_provider_id(slug)
132
+ status, body = self._request(
133
+ "POST",
134
+ "/v1/agent-auth/oauth-authorization-sessions",
135
+ body={
136
+ "provider_id": provider_id,
137
+ "principal_id": self.context.principal_id,
138
+ "agent_id": self.context.agent_id,
139
+ },
140
+ )
141
+ if status >= _HTTP_BAD_REQUEST:
142
+ raise CredentialStoreError(
143
+ "Agent Auth create oauth-authorization-session failed", status
144
+ )
145
+ auth_id = str(body.get("id") or "")
146
+ connect_url = str(body.get("verification_url") or "")
147
+ if not auth_id or not connect_url:
148
+ raise CredentialStoreError(
149
+ "Agent Auth oauth-authorization-session missing id or verification_url"
150
+ )
151
+ return {"slug": slug, "connect_url": connect_url, "auth_id": auth_id}
152
+
153
+ def resolve_oauth_provider_id(self, slug: str) -> str:
154
+ """oauth-providers has no slug filter, so the match is made across pages."""
155
+ cursor: str | None = None
156
+ for _ in range(AGENT_AUTH_MAX_PROVIDER_PAGES):
157
+ query = {"page_size": str(AGENT_AUTH_PROVIDER_PAGE_SIZE)}
158
+ if cursor:
159
+ query["cursor"] = cursor
160
+ status, body = self._request(
161
+ "GET", f"/v1/agent-auth/oauth-providers?{urllib.parse.urlencode(query)}"
162
+ )
163
+ if status >= _HTTP_BAD_REQUEST:
164
+ raise CredentialStoreError("Agent Auth list oauth-providers failed", status)
165
+ raw_items = body.get("items")
166
+ for item in raw_items if isinstance(raw_items, list) else []:
167
+ if isinstance(item, Mapping) and item.get("slug") == slug and item.get("id"):
168
+ return str(item["id"])
169
+ next_cursor = body.get("next_cursor")
170
+ if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor:
171
+ raise AgentAuthUnknownProviderError(slug)
172
+ cursor = next_cursor
173
+ raise CredentialStoreError("Agent Auth oauth-provider list returned too many pages")
174
+
175
+ def find_credential_id(
176
+ self,
177
+ *,
178
+ owner_type: str,
179
+ owner_id: str,
180
+ kind: str,
181
+ slug: str,
182
+ ) -> str | None:
183
+ """Find a credential id by slug.
184
+
185
+ The credential key is ``credential_id``, never ``id``.
186
+ """
187
+ query = agent_auth_credential_query(
188
+ owner_type=owner_type, owner_id=owner_id, kind=kind, slug=slug
189
+ )
190
+ status, body = self._request("GET", f"/v1/agent-auth/credentials?{query}")
191
+ if status >= _HTTP_BAD_REQUEST:
192
+ raise CredentialStoreError("Agent Auth list credentials failed", status)
193
+ items = body.get("items")
194
+ row = items[0] if isinstance(items, list) and items else None
195
+ credential_id = row.get("credential_id") if isinstance(row, Mapping) else None
196
+ return credential_id if isinstance(credential_id, str) else None
197
+
198
+ def read_secret(self, credential_id: str) -> str | None:
199
+ status, body = self._request(
200
+ "GET",
201
+ f"/v1/agent-auth/credentials/{_path_segment(credential_id)}/secret",
202
+ )
203
+ if status == _HTTP_NOT_FOUND:
204
+ return None
205
+ if status >= _HTTP_BAD_REQUEST:
206
+ raise CredentialStoreError("Agent Auth get secret failed", status)
207
+ secret = body.get("secret")
208
+ return secret if isinstance(secret, str) and secret else None
209
+
210
+ def _fetch_access_token(self, credential_id: str) -> str | None:
211
+ status, body = self._request(
212
+ "GET",
213
+ f"/v1/agent-auth/credentials/{_path_segment(credential_id)}/oauth2",
214
+ )
215
+ if _is_authorization_required(status, body):
216
+ return None
217
+ if status >= _HTTP_BAD_REQUEST:
218
+ raise CredentialStoreError("Agent Auth get oauth2 failed", status)
219
+ oauth2 = body.get("oauth2") if isinstance(body.get("oauth2"), dict) else body
220
+ token = oauth2.get("access_token") if isinstance(oauth2, dict) else None
221
+ return token if isinstance(token, str) and token else None
222
+
223
+ def _urlopen_json(
224
+ self,
225
+ method: str,
226
+ path: str,
227
+ *,
228
+ body: dict[str, object] | None = None,
229
+ ) -> tuple[int, dict[str, object]]:
230
+ if not self.context.base_url.startswith(("http://", "https://")):
231
+ raise ValueError("Agent Auth URL must be an http(s) URL")
232
+ return request_agent_auth_json(
233
+ method,
234
+ f"{self.context.base_url}{path}",
235
+ body=body,
236
+ headers=self._headers(),
237
+ timeout=self._timeout,
238
+ )
239
+
240
+ def _headers(self) -> dict[str, str]:
241
+ return agent_auth_headers(
242
+ api_key=self.context.api_key,
243
+ workspace_id=self.context.workspace_id,
244
+ )
245
+
246
+
247
+ def _path_segment(value: str) -> str:
248
+ return urllib.parse.quote(value, safe="")
249
+
250
+
251
+ def _is_authorization_required(status: int, body: dict[str, object]) -> bool:
252
+ """Agent Auth answers 409 when a stored grant has lapsed and needs re-auth."""
253
+ if status != _HTTP_CONFLICT:
254
+ return False
255
+ detail = str(body.get("detail") or body.get("title") or body.get("error") or "").lower()
256
+ return (
257
+ body.get("error") == "authorization_required"
258
+ or "authorization is required" in detail
259
+ or "oauth authorization" in detail
260
+ )
@@ -0,0 +1,119 @@
1
+ """Shared Agent Auth HTTP transport and credential-list paging.
2
+
3
+ Used by ``credentials.get`` and the credential gate so both talk to the same
4
+ API with the same headers, redirect policy, and page walker.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from collections.abc import Mapping
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from http.client import HTTPMessage
18
+ from typing import IO
19
+
20
+ AGENT_AUTH_TIMEOUT_SECONDS = 10.0
21
+
22
+
23
+ class CredentialStoreError(RuntimeError):
24
+ """Agent Auth could not answer.
25
+
26
+ Callers pass a fixed message: a response body can quote secret material, so
27
+ it never reaches this error, the logs, or the traceback chain.
28
+ """
29
+
30
+ def __init__(self, message: str, status: int | None = None) -> None:
31
+ self.status = status
32
+ super().__init__(message if status is None else f"{message} (status {status})")
33
+
34
+
35
+ __all__ = [
36
+ "AGENT_AUTH_TIMEOUT_SECONDS",
37
+ "CredentialStoreError",
38
+ "agent_auth_credential_query",
39
+ "agent_auth_headers",
40
+ "request_agent_auth_json",
41
+ ]
42
+
43
+
44
+ def agent_auth_credential_query(
45
+ *,
46
+ owner_type: str,
47
+ owner_id: str,
48
+ kind: str,
49
+ slug: str,
50
+ ) -> str:
51
+ """Query string that addresses one credential by its natural key."""
52
+ return urllib.parse.urlencode(
53
+ {"owner_type": owner_type, "owner_id": owner_id, "kind": kind, "slug": slug}
54
+ )
55
+
56
+
57
+ def agent_auth_headers(
58
+ *,
59
+ api_key: str | None = None,
60
+ workspace_id: str | None = None,
61
+ ) -> dict[str, str]:
62
+ headers = {
63
+ "accept": "application/json",
64
+ "content-type": "application/json",
65
+ "x-langsmith-product": "mda",
66
+ }
67
+ resolved_api_key = (api_key or "").strip()
68
+ if resolved_api_key:
69
+ headers["x-api-key"] = resolved_api_key
70
+ resolved_workspace = (workspace_id or "").strip()
71
+ if resolved_workspace:
72
+ headers["x-tenant-id"] = resolved_workspace
73
+ return headers
74
+
75
+
76
+ def _decode_json_object(raw: bytes) -> dict[str, object]:
77
+ try:
78
+ payload = json.loads(raw.decode("utf-8") or "{}")
79
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
80
+ return {}
81
+ return payload if isinstance(payload, dict) else {}
82
+
83
+
84
+ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
85
+ def redirect_request(
86
+ self,
87
+ req: urllib.request.Request,
88
+ fp: IO[bytes],
89
+ code: int,
90
+ msg: str,
91
+ headers: HTTPMessage,
92
+ newurl: str,
93
+ ) -> urllib.request.Request | None:
94
+ del req, fp, code, msg, headers, newurl
95
+ return None
96
+
97
+
98
+ def request_agent_auth_json(
99
+ method: str,
100
+ url: str,
101
+ *,
102
+ body: Mapping[str, object] | None = None,
103
+ headers: Mapping[str, str],
104
+ timeout: float,
105
+ ) -> tuple[int, dict[str, object]]:
106
+ """Issue one Agent Auth HTTP call: no redirects, bounded timeout, JSON body."""
107
+ data = None if body is None else json.dumps(dict(body)).encode("utf-8")
108
+ request = urllib.request.Request( # noqa: S310
109
+ url,
110
+ data=data,
111
+ headers=dict(headers),
112
+ method=method,
113
+ )
114
+ try:
115
+ opener = urllib.request.build_opener(_NoRedirectHandler())
116
+ with opener.open(request, timeout=timeout) as response:
117
+ return int(response.status), _decode_json_object(response.read())
118
+ except urllib.error.HTTPError as exc:
119
+ return int(exc.code), _decode_json_object(exc.read())
Binary file
@@ -9,7 +9,7 @@ name, before any managed ``{server}__`` prefix is applied.
9
9
  Example::
10
10
 
11
11
  # connectors/mcp.py
12
- from managed_deepagents import connectors
12
+ from managed_deepagents import connectors, credentials
13
13
 
14
14
  connector = connectors.mcp(
15
15
  use_standard_content_blocks=True,
@@ -21,6 +21,11 @@ Example::
21
21
  "url": "https://docs.example.com/mcp",
22
22
  "include_tools": ["search", "fetch"],
23
23
  },
24
+ "notion": {
25
+ "transport": "http",
26
+ "url": "https://mcp.notion.com/mcp",
27
+ "credential": credentials.get("notion", {"type": "user"}),
28
+ },
24
29
  },
25
30
  )
26
31
  """
@@ -42,6 +47,7 @@ from pydantic import (
42
47
 
43
48
  from managed_deepagents._connector import Connector
44
49
  from managed_deepagents._connectors.mcp.load import load_managed_mcp_tools
50
+ from managed_deepagents._credentials import CredentialReference # noqa: TC001
45
51
  from managed_deepagents._extension import define_extension
46
52
 
47
53
 
@@ -53,6 +59,9 @@ class McpToolSelectionConfig(TypedDict, total=False):
53
59
  class RemoteMcpServerBaseConfig(McpToolSelectionConfig, total=False):
54
60
  url: str
55
61
  headers: dict[str, str]
62
+ #: Bearer credential: an agent owner is a deployment-wide secret read once
63
+ #: at load, a user owner is the caller's OAuth grant loaded after the gate.
64
+ credential: CredentialReference
56
65
  #: Default timeout in seconds for tools loaded from this server.
57
66
  default_tool_timeout: float
58
67
 
@@ -142,7 +151,7 @@ class _RemoteMcpServer(BaseModel):
142
151
  ``default_tool_timeout``) are stripped before the client is constructed.
143
152
  """
144
153
 
145
- model_config = ConfigDict(extra="allow", validate_default=True)
154
+ model_config = ConfigDict(extra="allow", validate_default=True, arbitrary_types_allowed=True)
146
155
 
147
156
  # Both default to a value the validators below reject, so a missing field
148
157
  # reports the same message a malformed one does — as it does in Zod.
@@ -154,6 +163,7 @@ class _RemoteMcpServer(BaseModel):
154
163
  default_tool_timeout: float | None = None
155
164
  include_tools: list[_ToolName] | None = None
156
165
  exclude_tools: list[_ToolName] | None = None
166
+ credential: CredentialReference | None = None
157
167
 
158
168
  @field_validator("default_tool_timeout")
159
169
  @classmethod