cred-langchain 0.1.0__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.
- cred_langchain-0.1.0/.gitignore +50 -0
- cred_langchain-0.1.0/PKG-INFO +60 -0
- cred_langchain-0.1.0/README.md +45 -0
- cred_langchain-0.1.0/cred_langchain/__init__.py +32 -0
- cred_langchain-0.1.0/cred_langchain/toolkit.py +54 -0
- cred_langchain-0.1.0/cred_langchain/tools.py +150 -0
- cred_langchain-0.1.0/pyproject.toml +26 -0
- cred_langchain-0.1.0/tests/__init__.py +0 -0
- cred_langchain-0.1.0/tests/test_toolkit.py +280 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
build/
|
|
4
|
+
|
|
5
|
+
# Sprint artifacts
|
|
6
|
+
.factory/
|
|
7
|
+
.sprint-*
|
|
8
|
+
.sprint-pid
|
|
9
|
+
.rewire-done
|
|
10
|
+
.content-rewrite-done
|
|
11
|
+
.hubspot-done
|
|
12
|
+
.integrations-done
|
|
13
|
+
.done
|
|
14
|
+
.validation-report.md
|
|
15
|
+
.sprint-logs/
|
|
16
|
+
.sprint-prompts/
|
|
17
|
+
SESSION_PACKAGE.md
|
|
18
|
+
run-sprint.sh
|
|
19
|
+
validate.sh
|
|
20
|
+
|
|
21
|
+
# Internal planning docs (not for public repo)
|
|
22
|
+
CLAUDE.md
|
|
23
|
+
PLAN.md
|
|
24
|
+
HANDOFF.md
|
|
25
|
+
VALIDATION.md
|
|
26
|
+
MARKETING-VALIDATION.md
|
|
27
|
+
FINAL-SUBMISSION-VALIDATION.md
|
|
28
|
+
.next/
|
|
29
|
+
out/
|
|
30
|
+
coverage/
|
|
31
|
+
*.tsbuildinfo
|
|
32
|
+
.turbo/
|
|
33
|
+
.DS_Store
|
|
34
|
+
*.pem
|
|
35
|
+
.env
|
|
36
|
+
.env.local
|
|
37
|
+
.env*.local
|
|
38
|
+
!.env.example
|
|
39
|
+
|
|
40
|
+
# Python
|
|
41
|
+
__pycache__/
|
|
42
|
+
*.py[cod]
|
|
43
|
+
*.pyo
|
|
44
|
+
*.egg-info/
|
|
45
|
+
.pytest_cache/
|
|
46
|
+
.venv/
|
|
47
|
+
venv/
|
|
48
|
+
dist/
|
|
49
|
+
*.dist-info/
|
|
50
|
+
packages/sdk-python/.venv/
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cred-langchain
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cred LangChain integration — credential delegation tools for LangChain agents
|
|
5
|
+
Author-email: Cred <hello@cred.ninja>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: ai-agents,cred,delegation,langchain,oauth
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Requires-Dist: cred-auth>=0.1.0
|
|
10
|
+
Requires-Dist: langchain-core>=0.2
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: hatchling; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# cred-langchain
|
|
17
|
+
|
|
18
|
+
LangChain integration for Cred. OAuth2 credential delegation for AI agents.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install cred-langchain
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import os
|
|
30
|
+
from cred_langchain import CredToolkit
|
|
31
|
+
from cred import ConsentRequiredError
|
|
32
|
+
|
|
33
|
+
toolkit = CredToolkit(
|
|
34
|
+
agent_token=os.environ["CRED_AGENT_TOKEN"],
|
|
35
|
+
user_id="user_123",
|
|
36
|
+
)
|
|
37
|
+
tools = toolkit.get_tools()
|
|
38
|
+
# tools = [CredDelegateTool, CredStatusTool, CredRevokeTool]
|
|
39
|
+
|
|
40
|
+
# Use with any LangChain agent
|
|
41
|
+
from langchain.agents import AgentExecutor
|
|
42
|
+
agent = AgentExecutor(agent=..., tools=tools)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Tools
|
|
46
|
+
|
|
47
|
+
| Tool | Name | Description |
|
|
48
|
+
|------|------|-------------|
|
|
49
|
+
| `CredDelegateTool` | `cred_delegate` | Get an OAuth access token for a service |
|
|
50
|
+
| `CredStatusTool` | `cred_status` | List user's connected services |
|
|
51
|
+
| `CredRevokeTool` | `cred_revoke` | Revoke a service connection |
|
|
52
|
+
|
|
53
|
+
## Handling Consent
|
|
54
|
+
|
|
55
|
+
When a user hasn't connected a service yet, `CredDelegateTool` raises `ConsentRequiredError`.
|
|
56
|
+
Catch it and redirect the user to `e.consent_url` to complete the OAuth flow.
|
|
57
|
+
|
|
58
|
+
## Cred Cloud (Coming Soon)
|
|
59
|
+
|
|
60
|
+
Managed cloud delegation is coming. [Join the waitlist](https://cred.ninja/waitlist).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# cred-langchain
|
|
2
|
+
|
|
3
|
+
LangChain integration for Cred. OAuth2 credential delegation for AI agents.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cred-langchain
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from cred_langchain import CredToolkit
|
|
16
|
+
from cred import ConsentRequiredError
|
|
17
|
+
|
|
18
|
+
toolkit = CredToolkit(
|
|
19
|
+
agent_token=os.environ["CRED_AGENT_TOKEN"],
|
|
20
|
+
user_id="user_123",
|
|
21
|
+
)
|
|
22
|
+
tools = toolkit.get_tools()
|
|
23
|
+
# tools = [CredDelegateTool, CredStatusTool, CredRevokeTool]
|
|
24
|
+
|
|
25
|
+
# Use with any LangChain agent
|
|
26
|
+
from langchain.agents import AgentExecutor
|
|
27
|
+
agent = AgentExecutor(agent=..., tools=tools)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Tools
|
|
31
|
+
|
|
32
|
+
| Tool | Name | Description |
|
|
33
|
+
|------|------|-------------|
|
|
34
|
+
| `CredDelegateTool` | `cred_delegate` | Get an OAuth access token for a service |
|
|
35
|
+
| `CredStatusTool` | `cred_status` | List user's connected services |
|
|
36
|
+
| `CredRevokeTool` | `cred_revoke` | Revoke a service connection |
|
|
37
|
+
|
|
38
|
+
## Handling Consent
|
|
39
|
+
|
|
40
|
+
When a user hasn't connected a service yet, `CredDelegateTool` raises `ConsentRequiredError`.
|
|
41
|
+
Catch it and redirect the user to `e.consent_url` to complete the OAuth flow.
|
|
42
|
+
|
|
43
|
+
## Cred Cloud (Coming Soon)
|
|
44
|
+
|
|
45
|
+
Managed cloud delegation is coming. [Join the waitlist](https://cred.ninja/waitlist).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Cred LangChain integration."""
|
|
2
|
+
|
|
3
|
+
from pydantic import SecretStr
|
|
4
|
+
|
|
5
|
+
from cred import Cred
|
|
6
|
+
from .toolkit import CredToolkit
|
|
7
|
+
from .tools import CredDelegateTool, CredStatusTool, CredRevokeTool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def secret_from_cred(
|
|
11
|
+
service: str,
|
|
12
|
+
user_id: str,
|
|
13
|
+
cred: Cred,
|
|
14
|
+
scopes: list[str] | None = None,
|
|
15
|
+
app_client_id: str | None = None,
|
|
16
|
+
) -> SecretStr:
|
|
17
|
+
"""Drop-in replacement for secret_from_env() backed by Cred delegation."""
|
|
18
|
+
result = cred.delegate(
|
|
19
|
+
service=service,
|
|
20
|
+
user_id=user_id,
|
|
21
|
+
app_client_id=app_client_id,
|
|
22
|
+
scopes=scopes,
|
|
23
|
+
)
|
|
24
|
+
return SecretStr(result.access_token)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CredToolkit",
|
|
28
|
+
"CredDelegateTool",
|
|
29
|
+
"CredStatusTool",
|
|
30
|
+
"CredRevokeTool",
|
|
31
|
+
"secret_from_cred",
|
|
32
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Cred LangChain Toolkit — bundles all Cred tools for use with LangChain agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from langchain_core.tools import BaseTool
|
|
8
|
+
|
|
9
|
+
from cred import Cred
|
|
10
|
+
from .tools import CredDelegateTool, CredStatusTool, CredRevokeTool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CredToolkit:
|
|
14
|
+
"""LangChain toolkit that provides Cred credential delegation tools.
|
|
15
|
+
|
|
16
|
+
Instantiate once per agent invocation with the target user's ID.
|
|
17
|
+
All tools share the same Cred client and user context.
|
|
18
|
+
|
|
19
|
+
Example::
|
|
20
|
+
|
|
21
|
+
from cred_langchain import CredToolkit
|
|
22
|
+
|
|
23
|
+
toolkit = CredToolkit(
|
|
24
|
+
agent_token=os.environ["CRED_AGENT_TOKEN"],
|
|
25
|
+
user_id="user_123",
|
|
26
|
+
)
|
|
27
|
+
tools = toolkit.get_tools()
|
|
28
|
+
# Pass tools to your LangChain AgentExecutor
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
agent_token: str,
|
|
34
|
+
user_id: str,
|
|
35
|
+
base_url: str = "https://api.cred.ninja",
|
|
36
|
+
) -> None:
|
|
37
|
+
self._cred = Cred(agent_token=agent_token, base_url=base_url)
|
|
38
|
+
self._user_id = user_id
|
|
39
|
+
|
|
40
|
+
def get_tools(self) -> list[BaseTool]:
|
|
41
|
+
"""Return all Cred tools pre-configured with this toolkit's user context."""
|
|
42
|
+
delegate = CredDelegateTool()
|
|
43
|
+
delegate._cred = self._cred
|
|
44
|
+
delegate._user_id = self._user_id
|
|
45
|
+
|
|
46
|
+
status = CredStatusTool()
|
|
47
|
+
status._cred = self._cred
|
|
48
|
+
status._user_id = self._user_id
|
|
49
|
+
|
|
50
|
+
revoke = CredRevokeTool()
|
|
51
|
+
revoke._cred = self._cred
|
|
52
|
+
revoke._user_id = self._user_id
|
|
53
|
+
|
|
54
|
+
return [delegate, status, revoke]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Cred LangChain Tools — individual BaseTool implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Optional, Type
|
|
8
|
+
|
|
9
|
+
from langchain_core.tools import BaseTool
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from cred import Cred
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── Input schemas ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
class DelegateInput(BaseModel):
|
|
19
|
+
service: str = Field(description="Service slug to get a token for (e.g. 'google', 'github')")
|
|
20
|
+
app_client_id: str = Field(description="The Cred app client ID to delegate for")
|
|
21
|
+
scopes: list[str] = Field(
|
|
22
|
+
default_factory=list,
|
|
23
|
+
description="OAuth scopes to request (e.g. ['calendar.readonly']). "
|
|
24
|
+
"Leave empty to use all consented scopes.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RevokeInput(BaseModel):
|
|
29
|
+
service: str = Field(description="Service slug to revoke (e.g. 'google', 'github')")
|
|
30
|
+
app_client_id: Optional[str] = Field(
|
|
31
|
+
default=None,
|
|
32
|
+
description="Optional app client ID to scope the revocation.",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Tools ─────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
class CredDelegateTool(BaseTool):
|
|
39
|
+
"""Get an OAuth access token for a third-party service on behalf of the current user."""
|
|
40
|
+
|
|
41
|
+
name: str = "cred_delegate"
|
|
42
|
+
description: str = (
|
|
43
|
+
"Get an OAuth access token for a third-party service on behalf of the current user. "
|
|
44
|
+
"Returns the access token, expiry, service name, and granted scopes. "
|
|
45
|
+
"Raises ConsentRequiredError (with consent_url) if the user hasn't connected the service."
|
|
46
|
+
)
|
|
47
|
+
args_schema: Type[BaseModel] = DelegateInput
|
|
48
|
+
|
|
49
|
+
# Injected by CredToolkit
|
|
50
|
+
_cred: Any = None
|
|
51
|
+
_user_id: str = ""
|
|
52
|
+
_cache: dict[tuple[str, str, tuple[str, ...]], tuple[Any, float]] = {}
|
|
53
|
+
|
|
54
|
+
def _get_delegation_result(
|
|
55
|
+
self,
|
|
56
|
+
service: str,
|
|
57
|
+
app_client_id: str,
|
|
58
|
+
scopes: Optional[list[str]],
|
|
59
|
+
) -> Any:
|
|
60
|
+
resolved_scopes = scopes or []
|
|
61
|
+
cache_key = (service, app_client_id, tuple(resolved_scopes))
|
|
62
|
+
cached = self._cache.get(cache_key)
|
|
63
|
+
now = time.monotonic()
|
|
64
|
+
if cached and cached[1] > now:
|
|
65
|
+
return cached[0]
|
|
66
|
+
|
|
67
|
+
result = self._cred.delegate(
|
|
68
|
+
service=service,
|
|
69
|
+
user_id=self._user_id,
|
|
70
|
+
app_client_id=app_client_id,
|
|
71
|
+
scopes=resolved_scopes,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
expires_in = getattr(result, "expires_in", None)
|
|
75
|
+
refresh_deadline = 0.0
|
|
76
|
+
if isinstance(expires_in, int) and expires_in > 60:
|
|
77
|
+
refresh_deadline = now + expires_in - 60
|
|
78
|
+
self._cache[cache_key] = (result, refresh_deadline)
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
def _run(
|
|
82
|
+
self,
|
|
83
|
+
service: str,
|
|
84
|
+
app_client_id: str,
|
|
85
|
+
scopes: Optional[list[str]] = None,
|
|
86
|
+
**kwargs: Any,
|
|
87
|
+
) -> str:
|
|
88
|
+
result = self._get_delegation_result(service, app_client_id, scopes)
|
|
89
|
+
return json.dumps({
|
|
90
|
+
"access_token": result.access_token,
|
|
91
|
+
"token_type": result.token_type,
|
|
92
|
+
"expires_in": result.expires_in,
|
|
93
|
+
"service": result.service,
|
|
94
|
+
"scopes": result.scopes,
|
|
95
|
+
"delegation_id": result.delegation_id,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CredStatusTool(BaseTool):
|
|
100
|
+
"""Check which third-party services the current user has connected."""
|
|
101
|
+
|
|
102
|
+
name: str = "cred_status"
|
|
103
|
+
description: str = (
|
|
104
|
+
"Check which third-party services the current user has connected via OAuth. "
|
|
105
|
+
"Returns a list of connected services with their granted scopes."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Injected by CredToolkit
|
|
109
|
+
_cred: Any = None
|
|
110
|
+
_user_id: str = ""
|
|
111
|
+
|
|
112
|
+
def _run(self, **kwargs: Any) -> str:
|
|
113
|
+
connections = self._cred.get_user_connections(self._user_id)
|
|
114
|
+
return json.dumps([
|
|
115
|
+
{
|
|
116
|
+
"slug": c.slug,
|
|
117
|
+
"scopes_granted": c.scopes_granted,
|
|
118
|
+
"app_client_id": c.app_client_id,
|
|
119
|
+
"consented_at": c.consented_at,
|
|
120
|
+
}
|
|
121
|
+
for c in connections
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class CredRevokeTool(BaseTool):
|
|
126
|
+
"""Revoke the current user's connection to a third-party service."""
|
|
127
|
+
|
|
128
|
+
name: str = "cred_revoke"
|
|
129
|
+
description: str = (
|
|
130
|
+
"Revoke the current user's OAuth connection to a third-party service. "
|
|
131
|
+
"Use this when the user wants to disconnect a service."
|
|
132
|
+
)
|
|
133
|
+
args_schema: Type[BaseModel] = RevokeInput
|
|
134
|
+
|
|
135
|
+
# Injected by CredToolkit
|
|
136
|
+
_cred: Any = None
|
|
137
|
+
_user_id: str = ""
|
|
138
|
+
|
|
139
|
+
def _run(
|
|
140
|
+
self,
|
|
141
|
+
service: str,
|
|
142
|
+
app_client_id: Optional[str] = None,
|
|
143
|
+
**kwargs: Any,
|
|
144
|
+
) -> str:
|
|
145
|
+
self._cred.revoke(
|
|
146
|
+
service=service,
|
|
147
|
+
user_id=self._user_id,
|
|
148
|
+
app_client_id=app_client_id,
|
|
149
|
+
)
|
|
150
|
+
return json.dumps({"revoked": True, "service": service})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cred-langchain"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Cred LangChain integration — credential delegation tools for LangChain agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Cred", email = "hello@cred.ninja" }]
|
|
13
|
+
keywords = ["cred", "langchain", "oauth", "delegation", "ai-agents"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"cred-auth>=0.1.0",
|
|
16
|
+
"langchain-core>=0.2",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=7.0", "hatchling"]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["cred_langchain"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Cred LangChain integration tests.
|
|
2
|
+
|
|
3
|
+
Mocks the Cred client — no real HTTP, no real API calls.
|
|
4
|
+
Tests focus on: tool wiring, input schema, output shape, error propagation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import warnings
|
|
9
|
+
warnings.filterwarnings("ignore", category=UserWarning)
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
import httpx
|
|
16
|
+
from unittest.mock import MagicMock, patch
|
|
17
|
+
from langchain_core.tools import BaseTool
|
|
18
|
+
from pydantic import SecretStr
|
|
19
|
+
|
|
20
|
+
from cred import DelegationResult, Connection, ConsentRequiredError
|
|
21
|
+
from cred_langchain import (
|
|
22
|
+
CredToolkit,
|
|
23
|
+
CredDelegateTool,
|
|
24
|
+
CredStatusTool,
|
|
25
|
+
CredRevokeTool,
|
|
26
|
+
secret_from_cred,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TOKEN = "cred_at_test"
|
|
31
|
+
USER_ID = "user_123"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def make_delegation_result(
|
|
35
|
+
*,
|
|
36
|
+
access_token: str = "at",
|
|
37
|
+
token_type: str = "Bearer",
|
|
38
|
+
expires_in: int = 3600,
|
|
39
|
+
service: str = "google",
|
|
40
|
+
scopes: Optional[list[str]] = None,
|
|
41
|
+
delegation_id: str = "del_1",
|
|
42
|
+
) -> DelegationResult:
|
|
43
|
+
return DelegationResult(
|
|
44
|
+
access_token=access_token,
|
|
45
|
+
token_type=token_type,
|
|
46
|
+
expires_in=expires_in,
|
|
47
|
+
expires_at=datetime(2026, 3, 1, tzinfo=timezone.utc),
|
|
48
|
+
service=service,
|
|
49
|
+
scopes=scopes or [],
|
|
50
|
+
delegation_id=delegation_id,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def mock_cred():
|
|
56
|
+
return MagicMock()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.fixture
|
|
60
|
+
def toolkit(mock_cred):
|
|
61
|
+
tk = CredToolkit(agent_token=TOKEN, user_id=USER_ID)
|
|
62
|
+
tk._cred = mock_cred
|
|
63
|
+
return tk
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── CredToolkit ───────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
class TestCredToolkit:
|
|
69
|
+
def test_get_tools_returns_three_tools(self, toolkit):
|
|
70
|
+
tools = toolkit.get_tools()
|
|
71
|
+
assert len(tools) == 3
|
|
72
|
+
|
|
73
|
+
def test_all_tools_are_base_tool_instances(self, toolkit):
|
|
74
|
+
tools = toolkit.get_tools()
|
|
75
|
+
assert all(isinstance(t, BaseTool) for t in tools)
|
|
76
|
+
|
|
77
|
+
def test_tool_names(self, toolkit):
|
|
78
|
+
names = {t.name for t in toolkit.get_tools()}
|
|
79
|
+
assert names == {"cred_delegate", "cred_status", "cred_revoke"}
|
|
80
|
+
|
|
81
|
+
def test_tools_share_user_id(self, toolkit):
|
|
82
|
+
tools = toolkit.get_tools()
|
|
83
|
+
for tool in tools:
|
|
84
|
+
assert tool._user_id == USER_ID
|
|
85
|
+
|
|
86
|
+
def test_tools_share_cred_client(self, toolkit, mock_cred):
|
|
87
|
+
tools = toolkit.get_tools()
|
|
88
|
+
for tool in tools:
|
|
89
|
+
assert tool._cred is mock_cred
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── CredDelegateTool ──────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
class TestCredDelegateTool:
|
|
95
|
+
def test_returns_json_with_access_token(self, toolkit, mock_cred):
|
|
96
|
+
mock_cred.delegate.return_value = make_delegation_result(
|
|
97
|
+
access_token="ya29.mock",
|
|
98
|
+
expires_in=3600,
|
|
99
|
+
service="google",
|
|
100
|
+
scopes=["calendar.readonly"],
|
|
101
|
+
delegation_id="del_abc",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
tools = toolkit.get_tools()
|
|
105
|
+
delegate = next(t for t in tools if t.name == "cred_delegate")
|
|
106
|
+
result = delegate._run(
|
|
107
|
+
service="google",
|
|
108
|
+
app_client_id="app_1",
|
|
109
|
+
scopes=["calendar.readonly"],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
data = json.loads(result)
|
|
113
|
+
assert data["access_token"] == "ya29.mock"
|
|
114
|
+
assert data["service"] == "google"
|
|
115
|
+
assert data["expires_in"] == 3600
|
|
116
|
+
assert data["delegation_id"] == "del_abc"
|
|
117
|
+
|
|
118
|
+
def test_passes_user_id_to_cred(self, toolkit, mock_cred):
|
|
119
|
+
mock_cred.delegate.return_value = make_delegation_result()
|
|
120
|
+
|
|
121
|
+
tools = toolkit.get_tools()
|
|
122
|
+
delegate = next(t for t in tools if t.name == "cred_delegate")
|
|
123
|
+
delegate._run(service="google", app_client_id="app_1", scopes=[])
|
|
124
|
+
|
|
125
|
+
mock_cred.delegate.assert_called_once_with(
|
|
126
|
+
service="google",
|
|
127
|
+
user_id=USER_ID,
|
|
128
|
+
app_client_id="app_1",
|
|
129
|
+
scopes=[],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def test_propagates_consent_required_error(self, toolkit, mock_cred):
|
|
133
|
+
mock_cred.delegate.side_effect = ConsentRequiredError(
|
|
134
|
+
"User has not consented",
|
|
135
|
+
"https://api.cred.ninja/api/connect/google/authorize?app_client_id=app_1",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
tools = toolkit.get_tools()
|
|
139
|
+
delegate = next(t for t in tools if t.name == "cred_delegate")
|
|
140
|
+
|
|
141
|
+
with pytest.raises(ConsentRequiredError) as exc_info:
|
|
142
|
+
delegate._run(service="google", app_client_id="app_1", scopes=["calendar.readonly"])
|
|
143
|
+
|
|
144
|
+
assert "/api/connect/google/authorize" in exc_info.value.consent_url
|
|
145
|
+
|
|
146
|
+
def test_has_args_schema(self, toolkit):
|
|
147
|
+
tools = toolkit.get_tools()
|
|
148
|
+
delegate = next(t for t in tools if t.name == "cred_delegate")
|
|
149
|
+
schema = delegate.args_schema.model_json_schema()
|
|
150
|
+
assert "service" in schema["properties"]
|
|
151
|
+
assert "scopes" in schema["properties"]
|
|
152
|
+
|
|
153
|
+
def test_real_cred_client_delegates_against_mock_server(self):
|
|
154
|
+
toolkit = CredToolkit(
|
|
155
|
+
agent_token=TOKEN,
|
|
156
|
+
user_id=USER_ID,
|
|
157
|
+
base_url="https://cred.example.com",
|
|
158
|
+
)
|
|
159
|
+
delegate = next(t for t in toolkit.get_tools() if t.name == "cred_delegate")
|
|
160
|
+
|
|
161
|
+
with patch("httpx.Client.request", return_value=httpx.Response(
|
|
162
|
+
200,
|
|
163
|
+
json={
|
|
164
|
+
"access_token": "ya29.real",
|
|
165
|
+
"token_type": "Bearer",
|
|
166
|
+
"expires_in": 3600,
|
|
167
|
+
"service": "google",
|
|
168
|
+
"scopes": ["calendar.readonly"],
|
|
169
|
+
"delegation_id": "del_real",
|
|
170
|
+
},
|
|
171
|
+
)) as request_mock:
|
|
172
|
+
result = delegate._run(
|
|
173
|
+
service="google",
|
|
174
|
+
app_client_id="app_1",
|
|
175
|
+
scopes=["calendar.readonly"],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
payload = json.loads(result)
|
|
179
|
+
assert payload["access_token"] == "ya29.real"
|
|
180
|
+
assert request_mock.called
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ── CredStatusTool ────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
class TestCredStatusTool:
|
|
186
|
+
def test_returns_json_list_of_connections(self, toolkit, mock_cred):
|
|
187
|
+
mock_cred.get_user_connections.return_value = [
|
|
188
|
+
Connection(slug="google", scopes_granted=["calendar.readonly"],
|
|
189
|
+
app_client_id="app1", consented_at="2026-03-01T00:00:00Z"),
|
|
190
|
+
Connection(slug="github", scopes_granted=["repo"],
|
|
191
|
+
app_client_id="app1", consented_at="2026-03-01T00:00:00Z"),
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
tools = toolkit.get_tools()
|
|
195
|
+
status = next(t for t in tools if t.name == "cred_status")
|
|
196
|
+
result = status._run()
|
|
197
|
+
|
|
198
|
+
data = json.loads(result)
|
|
199
|
+
assert len(data) == 2
|
|
200
|
+
assert data[0]["slug"] == "google"
|
|
201
|
+
assert data[1]["slug"] == "github"
|
|
202
|
+
|
|
203
|
+
def test_passes_user_id_to_cred(self, toolkit, mock_cred):
|
|
204
|
+
mock_cred.get_user_connections.return_value = []
|
|
205
|
+
|
|
206
|
+
tools = toolkit.get_tools()
|
|
207
|
+
status = next(t for t in tools if t.name == "cred_status")
|
|
208
|
+
status._run()
|
|
209
|
+
|
|
210
|
+
mock_cred.get_user_connections.assert_called_once_with(USER_ID)
|
|
211
|
+
|
|
212
|
+
def test_returns_empty_list_when_no_connections(self, toolkit, mock_cred):
|
|
213
|
+
mock_cred.get_user_connections.return_value = []
|
|
214
|
+
tools = toolkit.get_tools()
|
|
215
|
+
status = next(t for t in tools if t.name == "cred_status")
|
|
216
|
+
result = status._run()
|
|
217
|
+
assert json.loads(result) == []
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ── CredRevokeTool ────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
class TestCredRevokeTool:
|
|
223
|
+
def test_returns_json_revoked_true(self, toolkit, mock_cred):
|
|
224
|
+
mock_cred.revoke.return_value = None
|
|
225
|
+
|
|
226
|
+
tools = toolkit.get_tools()
|
|
227
|
+
revoke = next(t for t in tools if t.name == "cred_revoke")
|
|
228
|
+
result = revoke._run(service="google")
|
|
229
|
+
|
|
230
|
+
data = json.loads(result)
|
|
231
|
+
assert data["revoked"] is True
|
|
232
|
+
assert data["service"] == "google"
|
|
233
|
+
|
|
234
|
+
def test_passes_user_id_and_service_to_cred(self, toolkit, mock_cred):
|
|
235
|
+
mock_cred.revoke.return_value = None
|
|
236
|
+
|
|
237
|
+
tools = toolkit.get_tools()
|
|
238
|
+
revoke = next(t for t in tools if t.name == "cred_revoke")
|
|
239
|
+
revoke._run(service="google", app_client_id="app1")
|
|
240
|
+
|
|
241
|
+
mock_cred.revoke.assert_called_once_with(
|
|
242
|
+
service="google",
|
|
243
|
+
user_id=USER_ID,
|
|
244
|
+
app_client_id="app1",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def test_has_args_schema(self, toolkit):
|
|
248
|
+
tools = toolkit.get_tools()
|
|
249
|
+
revoke = next(t for t in tools if t.name == "cred_revoke")
|
|
250
|
+
schema = revoke.args_schema.model_json_schema()
|
|
251
|
+
assert "service" in schema["properties"]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class TestSecretFromCred:
|
|
255
|
+
def test_returns_secret_str_from_real_cred_client(self):
|
|
256
|
+
from cred import Cred
|
|
257
|
+
|
|
258
|
+
cred = Cred(agent_token=TOKEN, base_url="https://cred.example.com")
|
|
259
|
+
|
|
260
|
+
with patch("httpx.Client.request", return_value=httpx.Response(
|
|
261
|
+
200,
|
|
262
|
+
json={
|
|
263
|
+
"access_token": "ya29.secret",
|
|
264
|
+
"token_type": "Bearer",
|
|
265
|
+
"expires_in": 3600,
|
|
266
|
+
"service": "google",
|
|
267
|
+
"scopes": ["calendar.readonly"],
|
|
268
|
+
"delegation_id": "del_secret",
|
|
269
|
+
},
|
|
270
|
+
)):
|
|
271
|
+
secret = secret_from_cred(
|
|
272
|
+
"google",
|
|
273
|
+
USER_ID,
|
|
274
|
+
cred,
|
|
275
|
+
scopes=["calendar.readonly"],
|
|
276
|
+
app_client_id="app_1",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
assert isinstance(secret, SecretStr)
|
|
280
|
+
assert secret.get_secret_value() == "ya29.secret"
|