optimizely-opal.opal-tools-sdk 0.1.41.dev0__tar.gz → 0.1.43.dev0__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 (33) hide show
  1. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/PKG-INFO +1 -1
  2. optimizely_opal_opal_tools_sdk-0.1.43.dev0/opal_tools_sdk/auth/__init__.py +6 -0
  3. optimizely_opal_opal_tools_sdk-0.1.43.dev0/opal_tools_sdk/auth/epi_hmac.py +156 -0
  4. optimizely_opal_opal_tools_sdk-0.1.43.dev0/opal_tools_sdk/auth/requires_auth.py +41 -0
  5. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/decorators.py +4 -0
  6. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/models.py +9 -3
  7. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/service.py +4 -0
  8. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/optimizely_opal.opal_tools_sdk.egg-info/PKG-INFO +1 -1
  9. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/optimizely_opal.opal_tools_sdk.egg-info/SOURCES.txt +3 -0
  10. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/pyproject.toml +3 -3
  11. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/setup.py +1 -1
  12. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_integration.py +19 -0
  13. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/README.md +0 -0
  14. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/__init__.py +0 -0
  15. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/_registry.py +0 -0
  16. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/config.py +0 -0
  17. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/context.py +0 -0
  18. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/logging.py +0 -0
  19. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/proteus.py +0 -0
  20. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/response.py +0 -0
  21. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/ui.py +0 -0
  22. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/optimizely_opal.opal_tools_sdk.egg-info/dependency_links.txt +0 -0
  23. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/optimizely_opal.opal_tools_sdk.egg-info/requires.txt +0 -0
  24. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/optimizely_opal.opal_tools_sdk.egg-info/top_level.txt +0 -0
  25. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/setup.cfg +0 -0
  26. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_async_tool.py +0 -0
  27. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_auth_middleware.py +0 -0
  28. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_dynamic_ui.py +0 -0
  29. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_hmac.py +0 -0
  30. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_nested_schema.py +0 -0
  31. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_proteus.py +0 -0
  32. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_response.py +0 -0
  33. {optimizely_opal_opal_tools_sdk-0.1.41.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_rollback.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-opal.opal-tools-sdk
3
- Version: 0.1.41.dev0
3
+ Version: 0.1.43.dev0
4
4
  Summary: SDK for creating Opal-compatible tools services
5
5
  Home-page: https://github.com/optimizely/opal-tools-sdk
6
6
  Author: Optimizely
@@ -0,0 +1,6 @@
1
+ """Authentication utilities for Opal Tools SDK."""
2
+
3
+ from .epi_hmac import EpiHmac
4
+ from .requires_auth import requires_auth
5
+
6
+ __all__ = ["EpiHmac", "requires_auth"]
@@ -0,0 +1,156 @@
1
+ """epi-hmac signing and verification utility.
2
+
3
+ Implements the canonical epi-hmac scheme used across Opal services
4
+ (API Gateway, system-tools, file-server, instructions-service).
5
+
6
+ Signing algorithm::
7
+
8
+ timestamp = milliseconds since epoch
9
+ nonce = UUID v4
10
+ body_md5 = base64(md5(body)) # empty body hashes to base64(md5(""))
11
+ message = app_key + method + endpoint + timestamp + nonce + body_md5
12
+ secret_bytes = base64_decode(hmac_secret)
13
+ signature = base64(HMAC-SHA256(secret_bytes, message))
14
+ header = "epi-hmac {app_key}:{timestamp}:{nonce}:{signature}"
15
+
16
+ Replay protection (v1 posture): verification enforces the 5-minute timestamp
17
+ window but does NOT track nonces, so a captured signed request can be replayed
18
+ within that window. Nonce dedup (e.g. a shared store) is the caller's
19
+ responsibility if stronger replay protection is required.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import binascii
26
+ import hashlib
27
+ import hmac as hmac_mod
28
+ import time
29
+ import uuid
30
+
31
+ _TIMESTAMP_WINDOW_MS = 300_000 # 5 minutes (past timestamps)
32
+ _MAX_FUTURE_SKEW_MS = 60_000 # 1 minute (clock skew tolerance)
33
+
34
+
35
+ class EpiHmac:
36
+ """epi-hmac signing and verification utility.
37
+
38
+ Args:
39
+ app_key: Credential identifier.
40
+ secret: Base64-encoded HMAC secret.
41
+ """
42
+
43
+ def __init__(self, app_key: str, secret: str) -> None:
44
+ self.app_key = app_key
45
+ # Fail closed on a malformed secret rather than silently decoding to the
46
+ # wrong bytes (b64decode without validate ignores non-alphabet chars).
47
+ try:
48
+ self._secret_bytes = base64.b64decode(secret, validate=True)
49
+ except (binascii.Error, ValueError) as exc:
50
+ raise ValueError("EpiHmac secret must be valid base64") from exc
51
+
52
+ def sign(
53
+ self,
54
+ method: str,
55
+ endpoint: str,
56
+ body: bytes = b"",
57
+ ) -> str:
58
+ """Generate an ``Authorization: epi-hmac ...`` header value.
59
+
60
+ Args:
61
+ method: HTTP method (GET, POST, etc.).
62
+ endpoint: Request path including query string.
63
+ body: Request body bytes (empty for GET/DELETE).
64
+
65
+ Returns:
66
+ The full Authorization header value.
67
+ """
68
+ timestamp = str(int(time.time() * 1000))
69
+ nonce = str(uuid.uuid4())
70
+ return self._build_header(method, endpoint, body, timestamp, nonce)
71
+
72
+ def verify(
73
+ self,
74
+ auth_header: str,
75
+ method: str,
76
+ endpoint: str,
77
+ body: bytes = b"",
78
+ ) -> bool:
79
+ """Verify an ``Authorization: epi-hmac ...`` header.
80
+
81
+ Checks app_key match, timestamp freshness (5-minute window),
82
+ and signature correctness using timing-safe comparison.
83
+
84
+ Args:
85
+ auth_header: The full Authorization header value.
86
+ method: HTTP method.
87
+ endpoint: Request path including query string.
88
+ body: Request body bytes.
89
+
90
+ Returns:
91
+ True if the header is valid.
92
+ """
93
+ if not auth_header.startswith("epi-hmac "):
94
+ return False
95
+
96
+ parts = auth_header[len("epi-hmac ") :].split(":")
97
+ if len(parts) != 4:
98
+ return False
99
+
100
+ recv_app_key, timestamp, nonce, recv_signature = parts
101
+
102
+ # Validate app_key
103
+ if not hmac_mod.compare_digest(recv_app_key, self.app_key):
104
+ return False
105
+
106
+ # Validate timestamp freshness: reject far-future timestamps (beyond clock
107
+ # skew) and old timestamps (beyond replay window). The original symmetric
108
+ # check `abs(now - ts) <= window` accepted timestamps up to 5 min in the
109
+ # FUTURE, doubling the replay surface. A future timestamp is never
110
+ # legitimate beyond small clock skew.
111
+ try:
112
+ ts_ms = int(timestamp)
113
+ except ValueError:
114
+ return False
115
+ now_ms = int(time.time() * 1000)
116
+ # Reject timestamps too far in the future (beyond clock skew tolerance)
117
+ if ts_ms - now_ms > _MAX_FUTURE_SKEW_MS:
118
+ return False
119
+ # Reject timestamps too far in the past (beyond replay window)
120
+ if now_ms - ts_ms > _TIMESTAMP_WINDOW_MS:
121
+ return False
122
+
123
+ # Recompute expected signature
124
+ expected_header = self._build_header(method, endpoint, body, timestamp, nonce)
125
+ expected_signature = expected_header.split(":")[-1]
126
+
127
+ return hmac_mod.compare_digest(recv_signature, expected_signature)
128
+
129
+ def _build_header(
130
+ self,
131
+ method: str,
132
+ endpoint: str,
133
+ body: bytes,
134
+ timestamp: str,
135
+ nonce: str,
136
+ ) -> str:
137
+ # Empty-body rule: when body is b"", we compute base64(md5(b"")) and
138
+ # include it in the signed message. The Python, C# and TypeScript SDKs
139
+ # all use this same canonical construction (proven by the shared golden
140
+ # vectors in test_hmac.py).
141
+ #
142
+ # CROSS-IMPL: this is the tool-provider trust boundary (TMS -> tool
143
+ # provider) and is SEPARATE from the api-gateway's inbound HMAC
144
+ # (external -> Opal). The gateway uses a different canonical (no body
145
+ # hash) on its own routes; the two boundaries are independent and are
146
+ # NOT expected to share a wire format. TMS itself has no HMAC
147
+ # implementation. This signer/verifier only needs to be internally
148
+ # consistent; do not assume interop with the gateway canonical.
149
+ body_md5 = base64.b64encode(hashlib.md5(body).digest()).decode()
150
+
151
+ message = f"{self.app_key}{method}{endpoint}{timestamp}{nonce}{body_md5}"
152
+ signature = base64.b64encode(
153
+ hmac_mod.new(self._secret_bytes, message.encode(), hashlib.sha256).digest()
154
+ ).decode()
155
+
156
+ return f"epi-hmac {self.app_key}:{timestamp}:{nonce}:{signature}"
@@ -0,0 +1,41 @@
1
+ from collections.abc import Callable
2
+ from functools import wraps
3
+
4
+ from fastapi import Header, HTTPException
5
+
6
+
7
+ def requires_auth(provider: str, scope_bundle: str, required: bool = True):
8
+ """Decorator to indicate that a tool requires authentication.
9
+
10
+ Args:
11
+ provider: Authentication provider (e.g., "google", "microsoft")
12
+ scope_bundle: Scope bundle required (e.g., "calendar", "drive")
13
+ required: Whether authentication is mandatory (default: True)
14
+
15
+ Returns:
16
+ Decorator function
17
+ """
18
+
19
+ def decorator(func: Callable):
20
+ @wraps(func)
21
+ async def wrapper(*args, authorization: str | None = Header(None), **kwargs):
22
+ if required and not authorization:
23
+ raise HTTPException(status_code=401, detail="Authentication required")
24
+
25
+ # The Tools Management Service will provide the appropriate token
26
+ # in the Authorization header
27
+ return await func(*args, authorization=authorization, **kwargs)
28
+
29
+ # Store auth requirements in function metadata
30
+ auth_req = {"provider": provider, "scope_bundle": scope_bundle, "required": required}
31
+
32
+ # Initialize the list if it doesn't exist
33
+ if not hasattr(wrapper, "__auth_requirements__"):
34
+ wrapper.__auth_requirements__ = [] # type: ignore[attr-defined]
35
+
36
+ # Add this auth requirement to the list
37
+ wrapper.__auth_requirements__.append(auth_req) # type: ignore[attr-defined]
38
+
39
+ return wrapper
40
+
41
+ return decorator
@@ -99,6 +99,7 @@ def tool(
99
99
  timeout: int | None = None,
100
100
  sensitive: str | bool | None = None,
101
101
  wait: bool = False,
102
+ skippable_interaction: bool = False,
102
103
  ):
103
104
  """Decorator to register a function as an Opal tool.
104
105
 
@@ -114,6 +115,8 @@ def tool(
114
115
  Example: "ui://my-app/create-form"
115
116
  wait: When True, invoking this tool pauses the agent execution until the caller
116
117
  signals completion.
118
+ skippable_interaction: When True, the tool's confirmation interaction (card) can be
119
+ skipped; a user who opts out has the tool run headless instead.
117
120
 
118
121
  Returns:
119
122
  Decorator function
@@ -309,6 +312,7 @@ def tool(
309
312
  timeout=timeout,
310
313
  sensitive=sensitive,
311
314
  wait=wait,
315
+ skippable_interaction=skippable_interaction,
312
316
  )
313
317
 
314
318
  return func
@@ -3,7 +3,7 @@ from enum import Enum
3
3
  from typing import Any, Literal
4
4
 
5
5
  from pydantic import BaseModel, Field
6
- from typing_extensions import TypedDict # pydantic requires this on Python < 3.12
6
+ from typing_extensions import NotRequired, TypedDict # pydantic requires this on Python < 3.12
7
7
 
8
8
 
9
9
  class ParameterType(str, Enum):
@@ -83,14 +83,17 @@ class InteractionContext:
83
83
  auth_data: AuthData | None = None
84
84
 
85
85
 
86
- class Environment(TypedDict, total=False):
86
+ class Environment(TypedDict):
87
87
  """Execution environment for an Opal tool.
88
88
 
89
89
  Interactive mode provides interaction islands, while headless does not.
90
+ `execution_mode` is always sent by TMS — required. `is_proteus_enabled`
91
+ was added later, so older TMS versions may omit it; mark it `NotRequired`
92
+ for cross-version forward compatibility.
90
93
  """
91
94
 
92
95
  execution_mode: Literal["headless", "interactive"]
93
- is_proteus_enabled: bool
96
+ is_proteus_enabled: NotRequired[bool]
94
97
 
95
98
 
96
99
  @dataclass
@@ -110,6 +113,7 @@ class Function:
110
113
  # to a non-empty advisory string when sensitive, else None.
111
114
  sensitive: str | bool | None = None
112
115
  wait: bool = False # When True, pauses the agent until the caller signals completion
116
+ skippable_interaction: bool = False # When True, the tool's confirmation card can be skipped
113
117
 
114
118
  def __post_init__(self) -> None:
115
119
  # Normalize `sensitive` to the wire shape: a single nullable advisory
@@ -144,6 +148,8 @@ class Function:
144
148
  result["sensitive"] = self.sensitive
145
149
  if self.wait:
146
150
  result["wait"] = self.wait
151
+ if self.skippable_interaction:
152
+ result["skippable_interaction"] = self.skippable_interaction
147
153
 
148
154
  return result
149
155
 
@@ -314,6 +314,7 @@ class ToolsService:
314
314
  timeout: int | None = None,
315
315
  sensitive: str | bool | None = None,
316
316
  wait: bool = False,
317
+ skippable_interaction: bool = False,
317
318
  ) -> None:
318
319
  """Register a tool function.
319
320
 
@@ -329,6 +330,8 @@ class ToolsService:
329
330
  timeout: Timeout in seconds for async mode
330
331
  sensitive: Sensitive data field declaration
331
332
  wait: When True, invoking this tool pauses the agent until the caller signals completion
333
+ skippable_interaction: When True, the tool's confirmation interaction (card) can be
334
+ skipped; a user who opts out has the tool run headless instead
332
335
  """
333
336
  logger.info(f"Registering tool: {name} with endpoint: {endpoint}")
334
337
 
@@ -359,6 +362,7 @@ class ToolsService:
359
362
  timeout=timeout,
360
363
  sensitive=sensitive,
361
364
  wait=wait,
365
+ skippable_interaction=skippable_interaction,
362
366
  )
363
367
 
364
368
  self.functions.append(function)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-opal.opal-tools-sdk
3
- Version: 0.1.41.dev0
3
+ Version: 0.1.43.dev0
4
4
  Summary: SDK for creating Opal-compatible tools services
5
5
  Home-page: https://github.com/optimizely/opal-tools-sdk
6
6
  Author: Optimizely
@@ -12,6 +12,9 @@ opal_tools_sdk/proteus.py
12
12
  opal_tools_sdk/response.py
13
13
  opal_tools_sdk/service.py
14
14
  opal_tools_sdk/ui.py
15
+ opal_tools_sdk/auth/__init__.py
16
+ opal_tools_sdk/auth/epi_hmac.py
17
+ opal_tools_sdk/auth/requires_auth.py
15
18
  optimizely_opal.opal_tools_sdk.egg-info/PKG-INFO
16
19
  optimizely_opal.opal_tools_sdk.egg-info/SOURCES.txt
17
20
  optimizely_opal.opal_tools_sdk.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optimizely-opal.opal-tools-sdk"
7
- version = "0.1.41-dev"
7
+ version = "0.1.43-dev"
8
8
  description = "SDK for creating Opal-compatible tools services"
9
9
  authors = [{ name = "Optimizely", email = "opal-team@optimizely.com" }]
10
10
  readme = "README.md"
@@ -32,8 +32,8 @@ dev = [
32
32
  "Homepage" = "https://github.com/optimizely/opal-tools-sdk"
33
33
  "Bug Tracker" = "https://github.com/optimizely/opal-tools-sdk/issues"
34
34
 
35
- [tool.setuptools]
36
- packages = ["opal_tools_sdk"]
35
+ [tool.setuptools.packages.find]
36
+ include = ["opal_tools_sdk*"]
37
37
 
38
38
  [tool.ruff]
39
39
  line-length = 100
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="optimizely-opal.opal-tools-sdk",
5
- version="0.1.41-dev",
5
+ version="0.1.43-dev",
6
6
  packages=find_packages(),
7
7
  install_requires=[
8
8
  "fastapi>=0.100.0",
@@ -860,3 +860,22 @@ def test_v1_tool_bare_dict_unchanged():
860
860
  assert "application/json" in resp.headers["content-type"]
861
861
  # Should NOT be wrapped in envelope
862
862
  assert resp.json() == {"v1": True, "name": "Dave"}
863
+
864
+
865
+ def test_tool_with_skippable_interaction_flag():
866
+ """A tool marked skippable_interaction advertises it in discovery; others omit the key."""
867
+ app = FastAPI()
868
+ _ = ToolsService(app)
869
+
870
+ @tool(name="skip_me", description="skippable tool", skippable_interaction=True)
871
+ async def skip_me(params: SimpleParams) -> dict:
872
+ return {"ok": True}
873
+
874
+ @tool(name="plain", description="plain tool")
875
+ async def plain(params: SimpleParams) -> dict:
876
+ return {"ok": True}
877
+
878
+ discovery = TestClient(app).get("/discovery").json()
879
+ by_name = {f["name"]: f for f in discovery["functions"]}
880
+ assert by_name["skip_me"]["skippable_interaction"] is True
881
+ assert "skippable_interaction" not in by_name["plain"]