claritty-sdk 2.2.0__tar.gz → 2.5.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.
Files changed (80) hide show
  1. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/PKG-INFO +1 -1
  2. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/__init__.py +4 -2
  3. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/context.py +15 -0
  4. claritty_sdk-2.5.0/claritty_sdk/files.py +229 -0
  5. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/catalog.py +2 -2
  6. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/executor_client.py +9 -1
  7. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/platform_creds.py +112 -5
  8. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/platform_client.py +23 -9
  9. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk.egg-info/PKG-INFO +1 -1
  10. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk.egg-info/SOURCES.txt +1 -0
  11. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/pyproject.toml +1 -1
  12. claritty_sdk-2.5.0/tests/test_integration_broker_dispatch.py +140 -0
  13. claritty_sdk-2.2.0/tests/test_integration_broker_dispatch.py +0 -60
  14. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/README.md +0 -0
  15. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/cli_eval.py +0 -0
  16. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/cli_verify.py +0 -0
  17. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/decorators.py +0 -0
  18. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/eval.py +0 -0
  19. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/graph.py +0 -0
  20. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/__init__.py +0 -0
  21. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/__init__.pyi +0 -0
  22. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/airtable.pyi +0 -0
  23. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/anthropic.pyi +0 -0
  24. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/asana.pyi +0 -0
  25. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/calendly.pyi +0 -0
  26. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/clickup.pyi +0 -0
  27. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/client.py +0 -0
  28. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/discord.pyi +0 -0
  29. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/dropbox.pyi +0 -0
  30. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/github.pyi +0 -0
  31. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/gitlab.pyi +0 -0
  32. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/gmail.pyi +0 -0
  33. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/google_calendar.pyi +0 -0
  34. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/google_drive.pyi +0 -0
  35. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/hubspot.pyi +0 -0
  36. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/jira.pyi +0 -0
  37. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/linear.pyi +0 -0
  38. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/linkedin.pyi +0 -0
  39. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/mailchimp.pyi +0 -0
  40. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/mcp_client.py +0 -0
  41. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/microsoft_teams.pyi +0 -0
  42. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/notion.pyi +0 -0
  43. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/notion_mcp.pyi +0 -0
  44. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/openai.pyi +0 -0
  45. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/outlook.pyi +0 -0
  46. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/outlook_calendar.pyi +0 -0
  47. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/pipedrive.pyi +0 -0
  48. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/postgres.pyi +0 -0
  49. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/posthog.pyi +0 -0
  50. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/s3.pyi +0 -0
  51. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/salesforce.pyi +0 -0
  52. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/slack.pyi +0 -0
  53. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/stripe.pyi +0 -0
  54. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/trello.pyi +0 -0
  55. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/twilio.pyi +0 -0
  56. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/integrations/x_twitter.pyi +0 -0
  57. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/llm.py +0 -0
  58. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/manifest.py +0 -0
  59. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/__init__.py +0 -0
  60. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/bootstrap.py +0 -0
  61. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/expressions.py +0 -0
  62. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/schema_validate.py +0 -0
  63. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/team_runner.py +0 -0
  64. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/tool_loop.py +0 -0
  65. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/tracing.py +0 -0
  66. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/runtime/workflow_engine.py +0 -0
  67. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk/validators.py +0 -0
  68. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk.egg-info/dependency_links.txt +0 -0
  69. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk.egg-info/entry_points.txt +0 -0
  70. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk.egg-info/requires.txt +0 -0
  71. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/claritty_sdk.egg-info/top_level.txt +0 -0
  72. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/setup.cfg +0 -0
  73. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_agent_reasoning.py +0 -0
  74. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_execution_end_to_end.py +0 -0
  75. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_graph_emitter.py +0 -0
  76. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_manifest_doc_parity.py +0 -0
  77. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_manifest_triggers.py +0 -0
  78. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_parallel_fanin.py +0 -0
  79. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_team_mode.py +0 -0
  80. {claritty_sdk-2.2.0 → claritty_sdk-2.5.0}/tests/test_workflow_engine_failfast.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claritty-sdk
3
- Version: 2.2.0
3
+ Version: 2.5.0
4
4
  Summary: Official SDK for building apps on the Claritty Platform
5
5
  Author: Claritty Platform
6
6
  License: MIT
@@ -39,7 +39,7 @@ decorator/registry runtime (``@workflow``/``@uses_agent``/``@trigger_template``,
39
39
  ``WorkflowExecutor``) has been removed — there is one canonical (v2) runtime.
40
40
  """
41
41
 
42
- from . import llm
42
+ from . import files, llm
43
43
  from .context import AgentContext, AgentResult, BaseAgent, ToolCtx, WorkflowContext
44
44
  from .decorators import (
45
45
  agent,
@@ -65,6 +65,8 @@ __all__ = [
65
65
  "list_registered_tool_ids",
66
66
  # llm
67
67
  "llm",
68
+ # per-(user, app) file storage
69
+ "files",
68
70
  # canvas graph (manifest-driven)
69
71
  "build_graph",
70
72
  # per-request user context
@@ -72,4 +74,4 @@ __all__ = [
72
74
  "use_user_context",
73
75
  ]
74
76
 
75
- __version__ = "2.2.0"
77
+ __version__ = "2.3.0"
@@ -145,6 +145,21 @@ class ToolCtx:
145
145
  )
146
146
  return self._integration_resolver(integration_id)
147
147
 
148
+ @property
149
+ def files(self) -> Any:
150
+ """Per-(user, app) file storage bound to this context's ``user_id``.
151
+
152
+ Returns a :class:`claritty_sdk.files.BoundFiles` exposing
153
+ ``upload_bytes`` / ``create_upload_url`` / ``list`` / ``download_url`` /
154
+ ``confirm`` / ``delete`` for the current user. The platform brokers all
155
+ access; the app never holds S3 credentials.
156
+ """
157
+ # Imported lazily so context.py stays import-light and httpx is only
158
+ # required when a tool actually touches files.
159
+ from .files import BoundFiles
160
+
161
+ return BoundFiles(self.user_id)
162
+
148
163
  def log(self, level: str, message: str) -> None:
149
164
  _log("tool", level, message)
150
165
 
@@ -0,0 +1,229 @@
1
+ """Per-(user, app) file storage for deployed apps.
2
+
3
+ End users can upload files THROUGH an app; each file is bound to the app
4
+ instance (``CLARITY_APP_ID``) and private to the uploading user. The app never
5
+ holds S3 credentials — it brokers every operation through the platform over
6
+ ``${CLARITTY_PLATFORM_URL}/internal/files/*`` with the ``CLARITY_INTERNAL_SECRET``
7
+ shared secret (the same trust model as integration credentials,
8
+ :mod:`claritty_sdk.integrations.platform_creds`). The platform validates that
9
+ ``user_id`` may access the app, then mints short-lived presigned S3 URLs.
10
+
11
+ Typical use inside a ``@tool`` handler::
12
+
13
+ from claritty_sdk import files
14
+
15
+ @tool(id="docs.save")
16
+ def save(input: dict, ctx: ToolCtx) -> dict:
17
+ info = files.upload_bytes(
18
+ filename=input["name"],
19
+ content_type=input["type"],
20
+ data=base64.b64decode(input["b64"]),
21
+ user_id=ctx.user_id,
22
+ )
23
+ return {"file_id": info["fileId"]}
24
+
25
+ Or via the bound accessor (``user_id`` taken from the context)::
26
+
27
+ info = ctx.files.upload_bytes(name, content_type, data)
28
+ items = ctx.files.list()
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ from typing import Any, Dict, List, Optional
35
+
36
+ import httpx
37
+
38
+ # Files can be large; allow a longer ceiling than the creds fetch.
39
+ _TIMEOUT_S = float(os.environ.get("CLARITTY_FILES_TIMEOUT_S", "60"))
40
+
41
+
42
+ class FileBrokerError(RuntimeError):
43
+ """A file broker call failed (misconfig, denied access, or platform error)."""
44
+
45
+
46
+ def _config() -> tuple[str, str, Optional[str]]:
47
+ base = (os.environ.get("CLARITTY_PLATFORM_URL") or "").rstrip("/")
48
+ secret = (
49
+ os.environ.get("CLARITY_INTERNAL_SECRET")
50
+ or os.environ.get("CLARITTY_INTERNAL_SECRET")
51
+ or ""
52
+ )
53
+ if not base or not secret:
54
+ raise FileBrokerError(
55
+ "File broker not configured (CLARITTY_PLATFORM_URL / "
56
+ "CLARITY_INTERNAL_SECRET missing). In a deployed app these are "
57
+ "injected by the platform."
58
+ )
59
+ app_id = os.environ.get("CLARITY_APP_ID") or os.environ.get("CLARITTY_APP_ID")
60
+ return base, secret, app_id
61
+
62
+
63
+ def _post(path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
64
+ base, secret, app_id = _config()
65
+ if not app_id:
66
+ raise FileBrokerError(
67
+ "CLARITY_APP_ID is not set — files are scoped per app and cannot "
68
+ "be brokered without it."
69
+ )
70
+ body = {"appId": app_id, **payload}
71
+ url = f"{base}/internal/files/{path}"
72
+ try:
73
+ resp = httpx.post(
74
+ url,
75
+ json=body,
76
+ headers={
77
+ "X-Claritty-Internal": secret,
78
+ "Content-Type": "application/json",
79
+ },
80
+ timeout=_TIMEOUT_S,
81
+ )
82
+ except httpx.HTTPError as exc:
83
+ raise FileBrokerError(f"file broker call {path!r} failed: {exc}") from exc
84
+ if resp.status_code == 401:
85
+ raise FileBrokerError(
86
+ "file broker returned 401 — check CLARITY_INTERNAL_SECRET."
87
+ )
88
+ if resp.status_code == 403:
89
+ raise FileBrokerError("user does not have access to this app's files.")
90
+ if resp.status_code >= 400:
91
+ raise FileBrokerError(
92
+ f"file broker {path!r} failed: HTTP {resp.status_code} {resp.text[:200]}"
93
+ )
94
+ try:
95
+ return resp.json() or {}
96
+ except ValueError as exc:
97
+ raise FileBrokerError(
98
+ f"file broker {path!r} returned a non-JSON response"
99
+ ) from exc
100
+
101
+
102
+ def create_upload_url(
103
+ filename: str,
104
+ content_type: str,
105
+ user_id: str,
106
+ *,
107
+ size_bytes: Optional[int] = None,
108
+ ) -> Dict[str, Any]:
109
+ """Reserve a file and get a presigned PUT URL.
110
+
111
+ Returns ``{"fileId", "uploadUrl", "contentType"}``. PUT the bytes to
112
+ ``uploadUrl`` with ``Content-Type: contentType``, then call :func:`confirm`.
113
+ Prefer :func:`upload_bytes` which does all three steps.
114
+ """
115
+ payload: Dict[str, Any] = {
116
+ "userId": user_id,
117
+ "filename": filename,
118
+ "contentType": content_type,
119
+ }
120
+ if size_bytes is not None:
121
+ payload["sizeBytes"] = int(size_bytes)
122
+ return _post("upload-url", payload)
123
+
124
+
125
+ def confirm(file_id: str, user_id: str) -> Dict[str, Any]:
126
+ """Mark an uploaded object READY (records its real size). Returns the file."""
127
+ return _post("confirm", {"userId": user_id, "fileId": file_id})
128
+
129
+
130
+ def list_files(user_id: str) -> List[Dict[str, Any]]:
131
+ """List the user's READY files for this app (newest first)."""
132
+ return _post("list", {"userId": user_id}).get("files", [])
133
+
134
+
135
+ def download_url(file_id: str, user_id: str) -> Dict[str, Any]:
136
+ """Get a short-lived presigned GET URL: ``{"url", "filename", "contentType"}``."""
137
+ return _post("download-url", {"userId": user_id, "fileId": file_id})
138
+
139
+
140
+ def delete(file_id: str, user_id: str) -> Dict[str, Any]:
141
+ """Delete one of the user's own files (object + metadata)."""
142
+ return _post("delete", {"userId": user_id, "fileId": file_id})
143
+
144
+
145
+ def upload_bytes(
146
+ filename: str,
147
+ content_type: str,
148
+ data: bytes,
149
+ user_id: str,
150
+ ) -> Dict[str, Any]:
151
+ """One-shot server-side upload: reserve → PUT to S3 → confirm.
152
+
153
+ Returns the confirmed file (``{"fileId", "filename", "sizeBytes", ...}``).
154
+ Note Lambda's ~6MB sync payload cap — for large browser uploads, hand the
155
+ presigned ``uploadUrl`` from :func:`create_upload_url` to the client and PUT
156
+ directly from the browser instead of streaming bytes through the app.
157
+ """
158
+ reserved = create_upload_url(
159
+ filename, content_type, user_id, size_bytes=len(data)
160
+ )
161
+ try:
162
+ put = httpx.put(
163
+ reserved["uploadUrl"],
164
+ content=data,
165
+ headers={"Content-Type": reserved.get("contentType", content_type)},
166
+ timeout=_TIMEOUT_S,
167
+ )
168
+ except httpx.HTTPError as exc:
169
+ raise FileBrokerError(f"upload PUT failed: {exc}") from exc
170
+ if put.status_code >= 400:
171
+ raise FileBrokerError(
172
+ f"upload PUT failed: HTTP {put.status_code} {put.text[:200]}"
173
+ )
174
+ return confirm(reserved["fileId"], user_id)
175
+
176
+
177
+ class BoundFiles:
178
+ """:mod:`claritty_sdk.files` with ``user_id`` pre-bound (see ``ToolCtx.files``)."""
179
+
180
+ def __init__(self, user_id: Optional[str]) -> None:
181
+ self._user_id = user_id
182
+
183
+ def _uid(self) -> str:
184
+ if not self._user_id:
185
+ raise FileBrokerError(
186
+ "no user_id on this context — files are per-user and cannot be "
187
+ "accessed without an authenticated user."
188
+ )
189
+ return self._user_id
190
+
191
+ def create_upload_url(
192
+ self,
193
+ filename: str,
194
+ content_type: str,
195
+ *,
196
+ size_bytes: Optional[int] = None,
197
+ ) -> Dict[str, Any]:
198
+ return create_upload_url(
199
+ filename, content_type, self._uid(), size_bytes=size_bytes
200
+ )
201
+
202
+ def upload_bytes(
203
+ self, filename: str, content_type: str, data: bytes
204
+ ) -> Dict[str, Any]:
205
+ return upload_bytes(filename, content_type, data, self._uid())
206
+
207
+ def confirm(self, file_id: str) -> Dict[str, Any]:
208
+ return confirm(file_id, self._uid())
209
+
210
+ def list(self) -> List[Dict[str, Any]]:
211
+ return list_files(self._uid())
212
+
213
+ def download_url(self, file_id: str) -> Dict[str, Any]:
214
+ return download_url(file_id, self._uid())
215
+
216
+ def delete(self, file_id: str) -> Dict[str, Any]:
217
+ return delete(file_id, self._uid())
218
+
219
+
220
+ __all__ = [
221
+ "FileBrokerError",
222
+ "create_upload_url",
223
+ "confirm",
224
+ "list_files",
225
+ "download_url",
226
+ "delete",
227
+ "upload_bytes",
228
+ "BoundFiles",
229
+ ]
@@ -91,8 +91,8 @@ def _wire_native_integration(
91
91
  """Register a PLATFORM-DELEGATING handler for each tool a native integration
92
92
  advertises. The real provider REST + the user's credentials live on the
93
93
  platform (the seed is a public repo), so the app never executes the call
94
- itself — it forwards to /internal/integrations/<id>/<tool>/execute via the
95
- SDK PlatformClient. Skips any tool already registered (a vendored handler,
94
+ itself — it forwards to /internal/integrations/tools/<id>/<tool>/execute via
95
+ the SDK PlatformClient. Skips any tool already registered (a vendored handler,
96
96
  if present, wins)."""
97
97
  for t in manifest.get("providedTools") or []:
98
98
  tool_id = (t or {}).get("id")
@@ -60,10 +60,18 @@ def execute_tool(
60
60
  app_id = os.environ.get("CLARITY_APP_ID")
61
61
  if app_id:
62
62
  payload["appId"] = app_id
63
+ headers = {"X-Claritty-Internal": secret}
64
+ # Per-app identity: PROVE our appId to the broker with the per-app secret
65
+ # (HMAC(master, appId), injected as CLARITY_APP_INTEGRATION_SECRET) so a
66
+ # different app can't claim ours. Omitted when absent (legacy) → broker falls
67
+ # back to the shared-secret path until strict mode.
68
+ app_secret = os.environ.get("CLARITY_APP_INTEGRATION_SECRET")
69
+ if app_secret:
70
+ headers["X-Claritty-App-Secret"] = app_secret
63
71
  try:
64
72
  resp = httpx.post(
65
73
  url,
66
- headers={"X-Claritty-Internal": secret},
74
+ headers=headers,
67
75
  json=payload,
68
76
  timeout=timeout,
69
77
  )
@@ -28,6 +28,26 @@ logger = logging.getLogger("claritty_sdk.integrations.platform_creds")
28
28
  _FETCH_TIMEOUT_S = float(os.environ.get("CLARITTY_CREDS_TIMEOUT_S", "10"))
29
29
 
30
30
 
31
+ def _broker_headers(secret: str) -> Dict[str, str]:
32
+ """Headers for a platform integration-broker call.
33
+
34
+ ``X-Claritty-Internal`` is the shared transport secret. ``X-Claritty-App-Secret``
35
+ is THIS app's PER-APP secret — ``HMAC(master, appId)``, injected by the
36
+ platform as ``CLARITY_APP_INTEGRATION_SECRET`` — which the broker verifies to
37
+ PROVE our ``appId`` so a different app can't claim ours and pull our users'
38
+ connections. Omitted when absent (legacy/local), so the broker falls back to
39
+ the shared-secret path until it's switched to strict mode.
40
+ """
41
+ headers = {
42
+ "X-Claritty-Internal": secret,
43
+ "Content-Type": "application/json",
44
+ }
45
+ app_secret = os.environ.get("CLARITY_APP_INTEGRATION_SECRET")
46
+ if app_secret:
47
+ headers["X-Claritty-App-Secret"] = app_secret
48
+ return headers
49
+
50
+
31
51
  class CredentialsNotAvailable(RuntimeError):
32
52
  """Raised when an integration's credentials cannot be fetched for
33
53
  the current user (not connected, expired, or — during Phase 2 —
@@ -122,10 +142,7 @@ def fetch_for_user(integration_id: str, user_id: str) -> Credentials:
122
142
  res = client.post(
123
143
  url,
124
144
  json=payload,
125
- headers={
126
- "X-Claritty-Internal": secret,
127
- "Content-Type": "application/json",
128
- },
145
+ headers=_broker_headers(secret),
129
146
  )
130
147
  except httpx.HTTPError as exc:
131
148
  # Network/timeout — surface as not-available so tools degrade, but log.
@@ -154,4 +171,94 @@ def fetch_for_user(integration_id: str, user_id: str) -> Credentials:
154
171
  )
155
172
 
156
173
 
157
- __all__ = ["Credentials", "CredentialsNotAvailable", "fetch_for_user"]
174
+ def is_connected(integration_id: str, user_id: str) -> bool:
175
+ """Whether ``integration_id`` is connected for ``user_id`` in THIS app.
176
+
177
+ A CREDENTIAL-FREE probe: it asks the platform's state endpoint whether a
178
+ connection exists WITHOUT ever fetching the raw token — so it works even for
179
+ brokered / gate-only integrations (where ``fetch_for_user`` is refused). Use
180
+ this for connect prompts / setup checklists; act on an integration only via
181
+ its registered tool handlers (which reach the platform broker).
182
+
183
+ Fail-closed: returns ``False`` on not-connected / misconfig / any transient
184
+ error, so a checklist degrades to "connect" rather than crashing. Scoped to
185
+ THIS app via ``CLARITY_APP_ID`` (same as ``fetch_for_user``).
186
+ """
187
+ # Local dev override mirrors fetch_for_user: a fake cred means "connected".
188
+ if os.environ.get(f"CLARITTY_FAKE_CREDS_{integration_id.upper()}"):
189
+ return True
190
+
191
+ base = (os.environ.get("CLARITTY_PLATFORM_URL") or "").rstrip("/")
192
+ secret = os.environ.get("CLARITY_INTERNAL_SECRET") or ""
193
+ if not base or not secret:
194
+ return False
195
+
196
+ import httpx
197
+
198
+ payload = {"userId": user_id, "integrationCatalogId": integration_id}
199
+ app_id = os.environ.get("CLARITY_APP_ID")
200
+ if app_id:
201
+ payload["appId"] = app_id
202
+ try:
203
+ with httpx.Client(timeout=_FETCH_TIMEOUT_S) as client:
204
+ res = client.post(
205
+ f"{base}/internal/integrations/state",
206
+ json=payload,
207
+ headers=_broker_headers(secret),
208
+ )
209
+ if res.status_code >= 400:
210
+ return False
211
+ return bool((res.json() or {}).get("connected"))
212
+ except Exception as exc: # noqa: BLE001 — a probe must never crash a checklist
213
+ logger.warning(
214
+ "connection state for integration=%s failed: %s", integration_id, exc
215
+ )
216
+ return False
217
+
218
+
219
+ def _platform_app_url() -> str:
220
+ """Public URL of the Claritty platform app (where the connect popup runs), as
221
+ distinct from CLARITTY_PLATFORM_URL (the internal API base the broker calls).
222
+ Read at call time so a late-set env var is honored."""
223
+ return (
224
+ os.environ.get("CLARITTY_PLATFORM_APP_URL") or "https://app.claritty.ai"
225
+ ).rstrip("/")
226
+
227
+
228
+ def connect_url(integration_id: str) -> Optional[str]:
229
+ """Deep link to the platform-hosted, per-(user,app) connect surface for
230
+ ``integration_id`` — the URL a deployed app opens (popup) so the user can
231
+ connect WITHOUT the app ever handling a credential. The popup runs at
232
+ ``app.claritty.ai`` and identifies the user by their platform session.
233
+
234
+ Returns ``None`` when ``CLARITY_APP_ID`` isn't set (can't scope the connect
235
+ to an app — e.g. local runs without the platform), so callers can omit the
236
+ CTA gracefully.
237
+ """
238
+ app_id = os.environ.get("CLARITY_APP_ID")
239
+ if not app_id:
240
+ return None
241
+ return f"{_platform_app_url()}/connect/{app_id}/{integration_id}"
242
+
243
+
244
+ def not_connected_error(integration_id: str) -> Dict[str, Any]:
245
+ """The STANDARD not-connected result every tool/adapter should return when an
246
+ integration isn't connected, so the app UI can render a "Connect X" CTA with
247
+ zero extra round-trips. Shape:
248
+ ``{"error": "<id>_not_connected", "service": <id>, "connect_url": <url|None>}``.
249
+ """
250
+ return {
251
+ "error": f"{integration_id}_not_connected",
252
+ "service": integration_id,
253
+ "connect_url": connect_url(integration_id),
254
+ }
255
+
256
+
257
+ __all__ = [
258
+ "Credentials",
259
+ "CredentialsNotAvailable",
260
+ "fetch_for_user",
261
+ "is_connected",
262
+ "connect_url",
263
+ "not_connected_error",
264
+ ]
@@ -134,8 +134,11 @@ class PlatformClient:
134
134
  # tool_id may arrive dotted (linkedin.create_post); the endpoint wants
135
135
  # the bare tool segment.
136
136
  bare = tool_id.split(".", 1)[1] if "." in tool_id else tool_id
137
+ # Canonical broker route is `/internal/integrations/tools/{id}/{tool}/execute`
138
+ # (see clarity-api InternalIntegrationsController). Earlier builds omitted
139
+ # the `tools/` segment and every brokered call 404'd — keep the segment.
137
140
  url = (
138
- f"{self.base_url}/internal/integrations/"
141
+ f"{self.base_url}/internal/integrations/tools/"
139
142
  f"{integration_id}/{bare}/execute"
140
143
  )
141
144
  # Per-app scoping: platform serves the connection bound to THIS app.
@@ -143,22 +146,33 @@ class PlatformClient:
143
146
  app_id = os.environ.get("CLARITY_APP_ID")
144
147
  if app_id:
145
148
  payload["appId"] = app_id
149
+ headers = {
150
+ "X-Claritty-Internal": self._secret,
151
+ "Content-Type": "application/json",
152
+ }
153
+ # Per-app identity: PROVE our appId to the broker with the per-app secret
154
+ # (HMAC(master, appId), injected as CLARITY_APP_INTEGRATION_SECRET) so a
155
+ # different app can't claim ours. Omitted when absent (legacy) → broker
156
+ # falls back to the shared-secret path until strict mode.
157
+ app_secret = os.environ.get("CLARITY_APP_INTEGRATION_SECRET")
158
+ if app_secret:
159
+ headers["X-Claritty-App-Secret"] = app_secret
146
160
  try:
147
161
  with httpx.Client(timeout=self._timeout) as http:
148
- res = http.post(
149
- url,
150
- json=payload,
151
- headers={
152
- "X-Claritty-Internal": self._secret,
153
- "Content-Type": "application/json",
154
- },
155
- )
162
+ res = http.post(url, json=payload, headers=headers)
156
163
  except httpx.HTTPError as exc:
157
164
  raise RuntimeError(
158
165
  f"integration tool {integration_id}.{bare} call failed: {exc}"
159
166
  ) from exc
160
167
  if res.status_code == 409:
161
168
  return {"error": f"{integration_id}_not_connected"}
169
+ if res.status_code == 404:
170
+ # The tool/route/executor isn't brokered for this app (unbrokered
171
+ # catalog integration, or a stale route). DEGRADE rather than raise —
172
+ # a brokered call is optional from the app's view, so a hard crash here
173
+ # would break an otherwise-working flow (this 500'd onboarding when
174
+ # brave-search had no executor). Callers see the not-connected signal.
175
+ return {"error": f"{integration_id}_not_connected"}
162
176
  if res.status_code == 401:
163
177
  raise RuntimeError(
164
178
  f"integration execute 401 from {url} — check CLARITY_INTERNAL_SECRET."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claritty-sdk
3
- Version: 2.2.0
3
+ Version: 2.5.0
4
4
  Summary: Official SDK for building apps on the Claritty Platform
5
5
  Author: Claritty Platform
6
6
  License: MIT
@@ -6,6 +6,7 @@ claritty_sdk/cli_verify.py
6
6
  claritty_sdk/context.py
7
7
  claritty_sdk/decorators.py
8
8
  claritty_sdk/eval.py
9
+ claritty_sdk/files.py
9
10
  claritty_sdk/graph.py
10
11
  claritty_sdk/llm.py
11
12
  claritty_sdk/manifest.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "claritty-sdk"
7
- version = "2.2.0"
7
+ version = "2.5.0"
8
8
  description = "Official SDK for building apps on the Claritty Platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,140 @@
1
+ """Phase 3: the agent tool-loop must BROKER integration-provided tools through
2
+ the platform executor — the credential never reaches the app. A catalog-source
3
+ tool with no locally-registered handler is dispatched via the platform delegate
4
+ (PlatformClient.execute_integration_tool), and only its result is returned.
5
+ """
6
+ import asyncio
7
+
8
+ from claritty_sdk.runtime import tool_loop
9
+ from claritty_sdk.runtime import platform_client
10
+ from claritty_sdk.manifest import ToolDecl
11
+ from claritty_sdk.context import AgentContext
12
+
13
+
14
+ def _ctx():
15
+ return AgentContext(user_id="u1")
16
+
17
+
18
+ def test_integration_tool_is_brokered(monkeypatch):
19
+ seen = {}
20
+
21
+ def fake_exec(self, integration_id, tool_id, user_id, arguments):
22
+ seen.update(
23
+ integration_id=integration_id,
24
+ tool_id=tool_id,
25
+ user_id=user_id,
26
+ arguments=arguments,
27
+ )
28
+ return {"message_id": "m1"}
29
+
30
+ monkeypatch.setattr(
31
+ platform_client.PlatformClient, "execute_integration_tool", fake_exec
32
+ )
33
+
34
+ toolset = {
35
+ "gmail.send": ToolDecl(
36
+ id="gmail.send", source="catalog", handler="", input={}, output={}
37
+ )
38
+ }
39
+ out = asyncio.run(
40
+ tool_loop._dispatch_tool(
41
+ "gmail.send", {"to": "x@y.com"}, toolset, _ctx(), None
42
+ )
43
+ )
44
+ # Only the result comes back — no credential.
45
+ assert out == {"message_id": "m1"}
46
+ assert seen["integration_id"] == "gmail"
47
+ assert seen["user_id"] == "u1"
48
+ assert seen["arguments"] == {"to": "x@y.com"}
49
+
50
+
51
+ def test_execute_posts_to_canonical_tools_route(monkeypatch):
52
+ """The broker POST must target the canonical
53
+ `/internal/integrations/tools/{id}/{tool}/execute` route the platform
54
+ serves. Omitting the `tools/` segment 404s every brokered call — this
55
+ guards that regression."""
56
+ captured = {}
57
+
58
+ class _Resp:
59
+ status_code = 200
60
+ content = b"{}"
61
+
62
+ def json(self):
63
+ return {"result": {"ok": True}}
64
+
65
+ class _FakeClient:
66
+ def __init__(self, *a, **k):
67
+ pass
68
+
69
+ def __enter__(self):
70
+ return self
71
+
72
+ def __exit__(self, *a):
73
+ return False
74
+
75
+ def post(self, url, **kwargs):
76
+ captured["url"] = url
77
+ captured["json"] = kwargs.get("json")
78
+ return _Resp()
79
+
80
+ monkeypatch.setenv("CLARITTY_PLATFORM_URL", "https://api.claritty.ai")
81
+ monkeypatch.setenv("CLARITY_INTERNAL_SECRET", "sec")
82
+ monkeypatch.setattr(platform_client.httpx, "Client", _FakeClient)
83
+
84
+ out = platform_client.PlatformClient().execute_integration_tool(
85
+ "hubspot", "hubspot.search_contacts", "u1", {"query": "acme"}
86
+ )
87
+
88
+ assert out == {"ok": True}
89
+ assert (
90
+ captured["url"]
91
+ == "https://api.claritty.ai/internal/integrations/tools/hubspot/search_contacts/execute"
92
+ )
93
+ assert captured["json"]["userId"] == "u1"
94
+
95
+
96
+ def test_404_degrades_to_not_connected(monkeypatch):
97
+ """An unbrokered integration / stale route (404) must DEGRADE to a
98
+ not-connected signal, not raise — a raise here 500'd onboarding when
99
+ `brave-search.search` had no executor."""
100
+ class _Resp:
101
+ status_code = 404
102
+ content = b'{"message":"Cannot POST ..."}'
103
+ text = '{"message":"Cannot POST ..."}'
104
+
105
+ def json(self):
106
+ return {}
107
+
108
+ class _FakeClient:
109
+ def __init__(self, *a, **k):
110
+ pass
111
+
112
+ def __enter__(self):
113
+ return self
114
+
115
+ def __exit__(self, *a):
116
+ return False
117
+
118
+ def post(self, url, **kwargs):
119
+ return _Resp()
120
+
121
+ monkeypatch.setenv("CLARITTY_PLATFORM_URL", "https://api.claritty.ai")
122
+ monkeypatch.setenv("CLARITY_INTERNAL_SECRET", "sec")
123
+ monkeypatch.setattr(platform_client.httpx, "Client", _FakeClient)
124
+
125
+ out = platform_client.PlatformClient().execute_integration_tool(
126
+ "brave-search", "brave-search.search", "u1", {"query": "x"}
127
+ )
128
+ assert out == {"error": "brave-search_not_connected"}
129
+
130
+
131
+ def test_unknown_custom_tool_still_errors():
132
+ toolset = {
133
+ "foo.bar": ToolDecl(
134
+ id="foo.bar", source="custom", handler="", input={}, output={}
135
+ )
136
+ }
137
+ out = asyncio.run(
138
+ tool_loop._dispatch_tool("foo.bar", {}, toolset, _ctx(), None)
139
+ )
140
+ assert "no registered handler" in out.get("error", "")
@@ -1,60 +0,0 @@
1
- """Phase 3: the agent tool-loop must BROKER integration-provided tools through
2
- the platform executor — the credential never reaches the app. A catalog-source
3
- tool with no locally-registered handler is dispatched via the platform delegate
4
- (PlatformClient.execute_integration_tool), and only its result is returned.
5
- """
6
- import asyncio
7
-
8
- from claritty_sdk.runtime import tool_loop
9
- from claritty_sdk.runtime import platform_client
10
- from claritty_sdk.manifest import ToolDecl
11
- from claritty_sdk.context import AgentContext
12
-
13
-
14
- def _ctx():
15
- return AgentContext(user_id="u1")
16
-
17
-
18
- def test_integration_tool_is_brokered(monkeypatch):
19
- seen = {}
20
-
21
- def fake_exec(self, integration_id, tool_id, user_id, arguments):
22
- seen.update(
23
- integration_id=integration_id,
24
- tool_id=tool_id,
25
- user_id=user_id,
26
- arguments=arguments,
27
- )
28
- return {"message_id": "m1"}
29
-
30
- monkeypatch.setattr(
31
- platform_client.PlatformClient, "execute_integration_tool", fake_exec
32
- )
33
-
34
- toolset = {
35
- "gmail.send": ToolDecl(
36
- id="gmail.send", source="catalog", handler="", input={}, output={}
37
- )
38
- }
39
- out = asyncio.run(
40
- tool_loop._dispatch_tool(
41
- "gmail.send", {"to": "x@y.com"}, toolset, _ctx(), None
42
- )
43
- )
44
- # Only the result comes back — no credential.
45
- assert out == {"message_id": "m1"}
46
- assert seen["integration_id"] == "gmail"
47
- assert seen["user_id"] == "u1"
48
- assert seen["arguments"] == {"to": "x@y.com"}
49
-
50
-
51
- def test_unknown_custom_tool_still_errors():
52
- toolset = {
53
- "foo.bar": ToolDecl(
54
- id="foo.bar", source="custom", handler="", input={}, output={}
55
- )
56
- }
57
- out = asyncio.run(
58
- tool_loop._dispatch_tool("foo.bar", {}, toolset, _ctx(), None)
59
- )
60
- assert "no registered handler" in out.get("error", "")
File without changes
File without changes