stacklink-sdk 0.2.2__tar.gz → 0.3.7__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 (42) hide show
  1. {stacklink_sdk-0.2.2/stacklink_sdk.egg-info → stacklink_sdk-0.3.7}/PKG-INFO +29 -2
  2. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/README.md +28 -1
  3. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/pyproject.toml +1 -1
  4. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/__init__.py +8 -0
  5. stacklink_sdk-0.3.7/stacklink/approvals.py +118 -0
  6. stacklink_sdk-0.3.7/stacklink/bots.py +266 -0
  7. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/client.py +7 -1
  8. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/webhooks.py +20 -0
  9. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/workbench.py +12 -1
  10. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7/stacklink_sdk.egg-info}/PKG-INFO +29 -2
  11. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink_sdk.egg-info/SOURCES.txt +2 -0
  12. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/tests/test_client.py +143 -2
  13. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/LICENSE +0 -0
  14. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/setup.cfg +0 -0
  15. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/api_keys.py +0 -0
  16. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/artifacts.py +0 -0
  17. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/auth_configs.py +0 -0
  18. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/billing.py +0 -0
  19. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/claude_agent.py +0 -0
  20. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/connected_accounts.py +0 -0
  21. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/errors.py +0 -0
  22. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/executions.py +0 -0
  23. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/knowledge.py +0 -0
  24. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/logs.py +0 -0
  25. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/mcp.py +0 -0
  26. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/memory.py +0 -0
  27. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/openai_agents.py +0 -0
  28. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/openai_memory.py +0 -0
  29. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/projects.py +0 -0
  30. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/providers.py +0 -0
  31. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/sessions.py +0 -0
  32. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/tools.py +0 -0
  33. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/triggers.py +0 -0
  34. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink/types.py +0 -0
  35. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink_sdk.egg-info/dependency_links.txt +0 -0
  36. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink_sdk.egg-info/requires.txt +0 -0
  37. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/stacklink_sdk.egg-info/top_level.txt +0 -0
  38. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/tests/test_google_docs_provider.py +0 -0
  39. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/tests/test_google_drive_provider.py +0 -0
  40. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/tests/test_google_sheets_provider.py +0 -0
  41. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/tests/test_openai_memory.py +0 -0
  42. {stacklink_sdk-0.2.2 → stacklink_sdk-0.3.7}/tests/test_zoho_books_provider.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stacklink-sdk
3
- Version: 0.2.2
3
+ Version: 0.3.7
4
4
  Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
5
5
  Author: Stacklink
6
6
  License-Expression: LicenseRef-Proprietary
@@ -77,8 +77,35 @@ An execution response can be pending. Keep its `executionKind`, `executionId`, s
77
77
 
78
78
  Retry keys are case-sensitive and accept up to 240 characters. Tool and batch requests have separate key namespaces. Reuse a key only for the exact same request in the same SDK session. Changed inputs return `409 idempotency_key_reused`; an expired stored result returns `410 execution_result_expired`. Neither response authorizes submitting the write again.
79
79
 
80
- For `waiting_for_approval`, your backend can use the session approval read, approve and deny APIs with `platform_approvals:read` / `platform_approvals:manage` scopes. Keep the API key on your backend. A pending action must not be labelled completed. `provider_execution_result_unknown` means an external write may have happened; inspect the provider before considering another action.
80
+ For `waiting_for_approval`, read the shared request using `client.approvals` and `platform_approvals:read`. A decision also needs `platform_approvals:manage` and the authority required by that request. A server key alone cannot approve on behalf of a company person; eligible customer confirmation uses the verified session and its scoped grant. Keep the API key on your backend. A pending action must not be labelled completed. `provider_execution_result_unknown` means an external write may have happened; inspect the provider before considering another action.
81
81
 
82
82
  Use execution cancellation for an explicit Stop request. Losing a stream only stops observation. Vercel and LangChain adapters forward their supported abort signal; other adapters keep the structured operation identity available for explicit cancellation. MCP progress reports actual stages, not invented percentages.
83
83
 
84
84
  Streaming has a total observation deadline and a separate inactivity timeout. A timeout raises `sdk_stream_timeout` without repeating the action; use its original operation ID to resume observation. Stream responses are limited to 8 MiB, including JSON responses to streaming requests. The synchronous client keeps blocking reads in a bounded reader and releases the response when that read finishes or reaches its inactivity timeout. Custom transports must honor the timeout passed to them. MCP calls return once all matching responses arrive, even if the server keeps the stream open.
85
+
86
+ ## Hosted Bots (0.3.2)
87
+
88
+ Bots are saved, published agents with project-scoped configuration and durable runs. Use a server-held project API key with `bots:manage` to create/update/publish, and `bots:run` to invoke. Supply a session established from your authenticated customer; input variables do not grant identity or access. Both SDKs expose run status, replayable events, cancellation and callback delivery recovery. A callback failure does not rerun the model.
89
+
90
+ ```python
91
+ bot = stacklink.bots.get(bot_id)
92
+ run = stacklink.bots.run(bot_id, session_id=session.id, message="Where is my order?", variables={"orderNumber": "4821"})
93
+ for event in stacklink.bots.stream(bot_id, run["id"]):
94
+ print(event)
95
+ result = stacklink.bots.get_run(bot_id, run["id"])
96
+ print(result["output"] or result["finalText"])
97
+ ```
98
+
99
+ The async client provides the same operations with `await` and `async for`.
100
+
101
+ Reuse the returned `conversationId` for the next message. Preserve the last event sequence to resume observation. Published invocations are available through the existing session MCP endpoint using `STACKLINK_BOTS`; no separate server per bot is needed.
102
+
103
+ ## Shared approvals (0.3.3)
104
+
105
+ `client.approvals` lists and retrieves approvals for an authorized session, submits a permitted decision or customer correction, and observes reconnectable approval events. A project key represents an application; it cannot substitute for a required human company approver. `as_customer=True` derives the customer from the authenticated session and still requires a scoped customer grant. Continue observing the original execution after deciding; do not resubmit the action.
106
+
107
+ All enabled notification channels use the same stored request. Decision, notification delivery and execution have separate states. See the [approval guide](https://docs.stacklink.in/docs/reference/sdk-python#shared-approvals) for request and lifecycle details.
108
+
109
+ When project settings require Workbench code approval, a foreground code/bash call returns `waiting_for_approval`, `approvalRequestId`, and its original execution ID immediately. No runtime sandbox is allocated for that wait. After the authorized person decides, observe the original execution; do not submit the code again.
110
+
111
+ Server-side customer grant management (0.3.5): `get_customer_grant`, `save_customer_grant`, and `remove_customer_grant` operate on an authenticated customer session. Save exact tool keys and resource restrictions with the returned settings revision. The server supplies identity from the session; callers cannot choose another customer in the request body. These methods never grant company approval or bypass connected-account access.
@@ -58,8 +58,35 @@ An execution response can be pending. Keep its `executionKind`, `executionId`, s
58
58
 
59
59
  Retry keys are case-sensitive and accept up to 240 characters. Tool and batch requests have separate key namespaces. Reuse a key only for the exact same request in the same SDK session. Changed inputs return `409 idempotency_key_reused`; an expired stored result returns `410 execution_result_expired`. Neither response authorizes submitting the write again.
60
60
 
61
- For `waiting_for_approval`, your backend can use the session approval read, approve and deny APIs with `platform_approvals:read` / `platform_approvals:manage` scopes. Keep the API key on your backend. A pending action must not be labelled completed. `provider_execution_result_unknown` means an external write may have happened; inspect the provider before considering another action.
61
+ For `waiting_for_approval`, read the shared request using `client.approvals` and `platform_approvals:read`. A decision also needs `platform_approvals:manage` and the authority required by that request. A server key alone cannot approve on behalf of a company person; eligible customer confirmation uses the verified session and its scoped grant. Keep the API key on your backend. A pending action must not be labelled completed. `provider_execution_result_unknown` means an external write may have happened; inspect the provider before considering another action.
62
62
 
63
63
  Use execution cancellation for an explicit Stop request. Losing a stream only stops observation. Vercel and LangChain adapters forward their supported abort signal; other adapters keep the structured operation identity available for explicit cancellation. MCP progress reports actual stages, not invented percentages.
64
64
 
65
65
  Streaming has a total observation deadline and a separate inactivity timeout. A timeout raises `sdk_stream_timeout` without repeating the action; use its original operation ID to resume observation. Stream responses are limited to 8 MiB, including JSON responses to streaming requests. The synchronous client keeps blocking reads in a bounded reader and releases the response when that read finishes or reaches its inactivity timeout. Custom transports must honor the timeout passed to them. MCP calls return once all matching responses arrive, even if the server keeps the stream open.
66
+
67
+ ## Hosted Bots (0.3.2)
68
+
69
+ Bots are saved, published agents with project-scoped configuration and durable runs. Use a server-held project API key with `bots:manage` to create/update/publish, and `bots:run` to invoke. Supply a session established from your authenticated customer; input variables do not grant identity or access. Both SDKs expose run status, replayable events, cancellation and callback delivery recovery. A callback failure does not rerun the model.
70
+
71
+ ```python
72
+ bot = stacklink.bots.get(bot_id)
73
+ run = stacklink.bots.run(bot_id, session_id=session.id, message="Where is my order?", variables={"orderNumber": "4821"})
74
+ for event in stacklink.bots.stream(bot_id, run["id"]):
75
+ print(event)
76
+ result = stacklink.bots.get_run(bot_id, run["id"])
77
+ print(result["output"] or result["finalText"])
78
+ ```
79
+
80
+ The async client provides the same operations with `await` and `async for`.
81
+
82
+ Reuse the returned `conversationId` for the next message. Preserve the last event sequence to resume observation. Published invocations are available through the existing session MCP endpoint using `STACKLINK_BOTS`; no separate server per bot is needed.
83
+
84
+ ## Shared approvals (0.3.3)
85
+
86
+ `client.approvals` lists and retrieves approvals for an authorized session, submits a permitted decision or customer correction, and observes reconnectable approval events. A project key represents an application; it cannot substitute for a required human company approver. `as_customer=True` derives the customer from the authenticated session and still requires a scoped customer grant. Continue observing the original execution after deciding; do not resubmit the action.
87
+
88
+ All enabled notification channels use the same stored request. Decision, notification delivery and execution have separate states. See the [approval guide](https://docs.stacklink.in/docs/reference/sdk-python#shared-approvals) for request and lifecycle details.
89
+
90
+ When project settings require Workbench code approval, a foreground code/bash call returns `waiting_for_approval`, `approvalRequestId`, and its original execution ID immediately. No runtime sandbox is allocated for that wait. After the authorized person decides, observe the original execution; do not submit the code again.
91
+
92
+ Server-side customer grant management (0.3.5): `get_customer_grant`, `save_customer_grant`, and `remove_customer_grant` operate on an authenticated customer session. Save exact tool keys and resource restrictions with the returned settings revision. The server supplies identity from the session; callers cannot choose another customer in the request body. These methods never grant company approval or bypass connected-account access.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stacklink-sdk"
7
- version = "0.2.2"
7
+ version = "0.3.7"
8
8
  description = "Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing."
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -1,5 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from .bots import verify_bot_api_request, sign_bot_trigger_request
4
+ from .approvals import ApprovalDecision, ApprovalRecord, ApprovalEvent, ApprovalPermissions, CustomerApprovalGrantInput
5
+ from .webhooks import verify_webhook_signature
3
6
  from .client import AsyncStacklink, STACKLINK_PYTHON_SDK_VERSION, Stacklink
4
7
  from .claude_agent import create_stacklink_claude_agent_tool_config
5
8
  from .errors import RateLimitError, StacklinkError
@@ -24,7 +27,12 @@ from .types import (
24
27
  )
25
28
 
26
29
  __all__ = [
30
+ "ApprovalDecision", "ApprovalRecord", "ApprovalEvent", "ApprovalPermissions",
31
+ "CustomerApprovalGrantInput",
27
32
  "Stacklink",
33
+ "verify_bot_api_request",
34
+ "sign_bot_trigger_request",
35
+ "verify_webhook_signature",
28
36
  "AsyncStacklink",
29
37
  "STACKLINK_PYTHON_SDK_VERSION",
30
38
  "StacklinkError",
@@ -0,0 +1,118 @@
1
+ """Shared approvals. Decisions apply to the stored action; never submit that action again."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import time
6
+ from typing import Any, Literal, TypedDict, Iterator, AsyncIterator
7
+ from urllib.parse import quote
8
+
9
+
10
+ class ApprovalDecision(TypedDict):
11
+ requirement: Literal['customer', 'company']
12
+ decision: Literal['approve', 'decline']
13
+ expectedRevision: int
14
+
15
+
16
+ class CustomerApprovalGrantInput(TypedDict):
17
+ revision: int
18
+ name: str
19
+ toolKeys: list[str]
20
+ accountIds: list[str]
21
+ resources: list[dict[str, str | int | float | bool]]
22
+
23
+
24
+ class ApprovalPermissions(TypedDict):
25
+ canDecide: bool
26
+ canManage: bool
27
+ requirement: Literal['customer', 'company'] | None
28
+ reason: str | None
29
+
30
+
31
+ class ApprovalRecord(TypedDict):
32
+ id: str
33
+ projectId: str
34
+ sessionId: str | None
35
+ revision: int
36
+ status: Literal['pending', 'approved', 'applying', 'applied', 'apply_failed', 'denied', 'cancelled', 'expired']
37
+ audience: Literal['approvers', 'customer', 'customer-approvers']
38
+ requirements: list[dict[str, Any]]
39
+ permissions: ApprovalPermissions
40
+ execution: dict[str, Any]
41
+ deliveries: list[dict[str, Any]]
42
+
43
+
44
+ class ApprovalEvent(TypedDict):
45
+ sequence: str
46
+ approvalId: str
47
+ type: Literal['approval.request.updated']
48
+ data: dict[str, Any]
49
+ occurredAt: str
50
+
51
+
52
+ class ApprovalsResource:
53
+ def __init__(self, client, *, async_mode: bool = False):
54
+ self.client = client
55
+ self.async_mode = async_mode
56
+
57
+ def get_customer_grant(self, *, session_id: str):
58
+ """Server-only: identity is resolved from this authenticated customer session."""
59
+ return self.client.request('GET', '/v1/approvals/customer-grant', query={'sessionId': session_id})
60
+
61
+ def save_customer_grant(self, grant: CustomerApprovalGrantInput, *, session_id: str):
62
+ return self.client.request('PUT', '/v1/approvals/customer-grant', query={'sessionId': session_id}, json_body=grant)
63
+
64
+ def remove_customer_grant(self, *, session_id: str, revision: int):
65
+ return self.client.request('DELETE', '/v1/approvals/customer-grant', query={'sessionId': session_id}, json_body={'revision': revision})
66
+
67
+ @staticmethod
68
+ def _query(session_id: str, as_customer: bool) -> dict[str, Any]:
69
+ return {'sessionId': session_id, 'asCustomer': 'true' if as_customer else 'false'}
70
+
71
+ def list(self, *, session_id: str, as_customer: bool = False, **filters):
72
+ return self.client.request('GET', '/v1/approvals', query={**filters, **self._query(session_id, as_customer)})
73
+
74
+ def get(self, approval_id: str, *, session_id: str, as_customer: bool = False):
75
+ return self.client.request('GET', '/v1/approvals/' + quote(approval_id, safe=''), query=self._query(session_id, as_customer))
76
+
77
+ def decide(self, approval_id: str, decision: ApprovalDecision, *, session_id: str, as_customer: bool = False, reason: str | None = None):
78
+ """Call only after a current authorized person confirms the displayed exact proposal."""
79
+ body = dict(decision)
80
+ if reason is not None:
81
+ body['reason'] = reason
82
+ return self.client.request('POST', '/v1/approvals/' + quote(approval_id, safe='') + '/decide', query=self._query(session_id, as_customer), json_body=body)
83
+
84
+ def change(self, approval_id: str, *, session_id: str, expected_revision: int, message: str, as_customer: bool = False):
85
+ return self.client.request('POST', '/v1/approvals/' + quote(approval_id, safe='') + '/change', query=self._query(session_id, as_customer), json_body={'expectedRevision': expected_revision, 'message': message})
86
+
87
+ def events(self, *, session_id: str, after: str = '0', as_customer: bool = False, timeout_ms: int = 15_000):
88
+ return self.client.request('GET', '/v1/approvals/events', query={**self._query(session_id, as_customer), 'after': after}, timeout_ms=timeout_ms)
89
+
90
+ def observe(self, *, session_id: str, after: str = '0', as_customer: bool = False, timeout_ms: int = 300_000) -> Iterator[ApprovalEvent] | AsyncIterator[ApprovalEvent]:
91
+ """Save each sequence and pass it back as after when reconnecting. Close the iterator to stop."""
92
+ if self.async_mode:
93
+ return self._observe_async(session_id, after, as_customer, timeout_ms)
94
+ return self._observe_sync(session_id, after, as_customer, timeout_ms)
95
+
96
+ def _observe_sync(self, session_id: str, cursor: str, as_customer: bool, timeout_ms: int) -> Iterator[ApprovalEvent]:
97
+ deadline = time.monotonic() + timeout_ms / 1000
98
+ while time.monotonic() < deadline:
99
+ page = self.events(session_id=session_id, after=cursor, as_customer=as_customer, timeout_ms=max(1, min(15_000, int((deadline - time.monotonic()) * 1000))))
100
+ for event in page['events']:
101
+ if int(event['sequence']) <= int(cursor):
102
+ continue
103
+ cursor = event['sequence']
104
+ yield event
105
+ if len(page['events']) < 100:
106
+ time.sleep(max(0, min(1, deadline - time.monotonic())))
107
+
108
+ async def _observe_async(self, session_id: str, cursor: str, as_customer: bool, timeout_ms: int) -> AsyncIterator[ApprovalEvent]:
109
+ deadline = time.monotonic() + timeout_ms / 1000
110
+ while time.monotonic() < deadline:
111
+ page = await self.events(session_id=session_id, after=cursor, as_customer=as_customer, timeout_ms=max(1, min(15_000, int((deadline - time.monotonic()) * 1000))))
112
+ for event in page['events']:
113
+ if int(event['sequence']) <= int(cursor):
114
+ continue
115
+ cursor = event['sequence']
116
+ yield event
117
+ if len(page['events']) < 100:
118
+ await asyncio.sleep(max(0, min(1, deadline - time.monotonic())))
@@ -0,0 +1,266 @@
1
+ """Hosted bot management and durable runs for sync and async clients."""
2
+ from __future__ import annotations
3
+ from typing import Any
4
+ from contextlib import closing, aclosing
5
+ import asyncio
6
+ import base64
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import time
11
+ from datetime import datetime
12
+ import httpx
13
+ from .errors import StacklinkError
14
+ from urllib.parse import quote
15
+ from uuid import uuid4
16
+
17
+
18
+ def sign_bot_trigger_request(*, trigger_id: str, secret: str, body: str, request_id: str | None = None, now: float | None = None) -> dict[str, str]:
19
+ """Sign the exact JSON bytes. Reuse request_id when retrying the same event."""
20
+ timestamp = str(int(time.time() if now is None else now))
21
+ request_id = request_id or str(uuid4())
22
+ digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
23
+ signed = json.dumps(["stacklink-trigger-v1", trigger_id, timestamp, request_id, digest], separators=(",", ":"), ensure_ascii=False)
24
+ signature = hmac.new(secret.encode("utf-8"), signed.encode("utf-8"), hashlib.sha256).hexdigest()
25
+ return {"content-type": "application/json", "stacklink-timestamp": timestamp, "stacklink-request-id": request_id, "stacklink-signature": signature}
26
+
27
+
28
+ def verify_bot_api_request(*, method: str, url: str, body: str, headers: dict[str, str], project_id: str, keys: dict[str, str | dict], consume_request_id, now: float | None = None, tolerance_seconds: int = 300):
29
+ """Verify raw request content; consume_request_id(id, expires_at) must atomically reject replays."""
30
+ normalized = {key.lower(): value for key, value in headers.items()}
31
+ def get(name):
32
+ return normalized.get("x-stacklink-" + name, "")
33
+ key_id, timestamp, request_id = get("key-id"), get("timestamp"), get("request-id")
34
+ secret, tenant, user = keys.get(key_id), get("tenant"), get("customer")
35
+ current_time = time.time() if now is None else now
36
+ if isinstance(secret, dict):
37
+ expiry = secret.get("validUntil")
38
+ if expiry is not None:
39
+ try:
40
+ if datetime.fromisoformat(expiry[:-1] + '+00:00' if expiry.endswith('Z') else expiry).timestamp() <= current_time:
41
+ return None
42
+ except (ValueError, TypeError):
43
+ return None
44
+ secret = secret.get("secret")
45
+ try:
46
+ seconds = int(timestamp)
47
+ except (ValueError, TypeError):
48
+ return None
49
+ if not isinstance(secret, str) or not secret or not tenant or not request_id or get("project") != project_id or abs(current_time - seconds) > tolerance_seconds:
50
+ return None
51
+ payload = json.dumps(["stacklink-api-v1", method.upper(), url, request_id, timestamp, key_id, project_id, tenant, user, hashlib.sha256(body.encode("utf-8")).hexdigest()], ensure_ascii=False, separators=(",", ":"))
52
+ expected = "v1," + base64.b64encode(hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).digest()).decode("ascii")
53
+ if not hmac.compare_digest(get("signature"), expected):
54
+ return None
55
+ if not consume_request_id(request_id, seconds + tolerance_seconds):
56
+ return None
57
+ return {"tenantId": tenant, "userId": user or None, "requestId": request_id}
58
+
59
+
60
+ class BotsResource:
61
+ def __init__(self, client, *, async_mode: bool = False):
62
+ self.client = client
63
+ self.async_mode = async_mode
64
+
65
+ def _path(self, bot_id: str, run_id: str | None = None) -> str:
66
+ path = f"/v1/bots/{quote(bot_id, safe='')}"
67
+ return f"{path}/runs/{quote(run_id, safe='')}" if run_id else path
68
+
69
+ def apis(self):
70
+ return self.client.request("GET", "/v1/bots/apis")
71
+
72
+ def update_api(self, api_id: str, revision: int, api: dict):
73
+ return self.client.request("PATCH", "/v1/bots/apis/" + quote(api_id, safe=""), json_body={"revision": revision, "api": api})
74
+
75
+ def delete_api(self, api_id: str):
76
+ return self.client.request("DELETE", "/v1/bots/apis/" + quote(api_id, safe=""))
77
+
78
+ def webhook_secret(self, bot_id: str):
79
+ return self.client.request("POST", self._path(bot_id) + "/webhook-secret", json_body={})
80
+
81
+ def trigger_webhook(self, bot_id: str, trigger_id: str):
82
+ return self.client.request("POST", self._path(bot_id) + "/triggers/" + quote(trigger_id, safe="") + "/webhook", json_body={})
83
+
84
+ def trigger_admission(self, bot_id: str, trigger_id: str, admission_id: str):
85
+ return self.client.request("GET", self._path(bot_id) + "/triggers/" + quote(trigger_id, safe="") + "/admissions/" + quote(admission_id, safe=""))
86
+
87
+ def delete_customer_data(self, bot_id: str, session_id: str):
88
+ """Erase this bot's customer data; shared Memory and API credentials remain unchanged."""
89
+ return self.client.request("POST", self._path(bot_id) + "/customer-data/delete", json_body={"sessionId": session_id})
90
+
91
+ def api_signing_key(self, bot_id: str, api_id: str, *, action: str = "read", overlap_seconds: int = 86400, key_id: str | None = None):
92
+ body: dict = {"action": action}
93
+ if action == "prepare":
94
+ body["overlapSeconds"] = overlap_seconds
95
+ if action in ("activate", "cancel"):
96
+ body["keyId"] = key_id
97
+ return self.client.request("POST", self._path(bot_id) + "/apis/" + quote(api_id, safe="") + "/signing-key", json_body=body)
98
+
99
+ def models(self):
100
+ return self.client.request("GET", "/v1/bots/models")
101
+
102
+ def set_credential(self, bot_id: str, secret: str):
103
+ return self.client.request("POST", self._path(bot_id) + "/credentials", json_body={"secret": secret})
104
+
105
+ def deliveries(self, bot_id: str, run_id: str):
106
+ return self.client.request("GET", self._path(bot_id, run_id) + "/deliveries")
107
+
108
+ def retry_delivery(self, bot_id: str, run_id: str, delivery_id: str):
109
+ return self.client.request("POST", self._path(bot_id, run_id) + "/deliveries/retry", json_body={"deliveryId": delivery_id})
110
+
111
+ def list(self):
112
+ return self.client.request("GET", "/v1/bots")
113
+
114
+ def delete(self, bot_id: str):
115
+ return self.client.request("DELETE", self._path(bot_id))
116
+
117
+ def set_state(self, bot_id: str, state: str, *, pause_for: str = "manual", reason: str = ""):
118
+ return self.client.request("POST", self._path(bot_id) + "/state", json_body={"state": state, "pauseFor": pause_for, "reason": reason})
119
+
120
+ def create(self, name: str):
121
+ return self.client.request("POST", "/v1/bots", json_body={"name": name})
122
+
123
+ def set_customer_credential(self, bot_id: str, *, api_id: str, session_id: str, secret: str):
124
+ return self.client.request("POST", self._path(bot_id) + "/credentials", json_body={"apiId": api_id, "sessionId": session_id, "secret": secret})
125
+
126
+ def get(self, bot_id: str):
127
+ return self.client.request("GET", self._path(bot_id))
128
+
129
+ def update(self, bot_id: str, revision: int, patch: dict[str, Any]):
130
+ return self.client.request("PATCH", self._path(bot_id), json_body={"revision": revision, "patch": patch})
131
+
132
+ def publish(self, bot_id: str, revision: int, channels: dict[str, bool]):
133
+ return self.client.request("POST", self._path(bot_id) + "/publish", json_body={"revision": revision, "channels": channels})
134
+
135
+ def run(self, bot_id: str, *, session_id: str, message: str, variables: dict[str, Any] | None = None, conversation_id: str | None = None, run_id: str | None = None):
136
+ body: dict[str, Any] = {"sessionId": session_id, "message": message, "variables": variables or {}, "runId": run_id or str(uuid4())}
137
+ if conversation_id:
138
+ body["conversationId"] = conversation_id
139
+ return self.client.request("POST", self._path(bot_id) + "/runs", json_body=body)
140
+
141
+ def get_run(self, bot_id: str, run_id: str):
142
+ return self.client.request("GET", self._path(bot_id, run_id))
143
+
144
+ def wait(self, bot_id: str, run_id: str, *, total_timeout_ms: int = 300_000):
145
+ """Return the finished run, or a run waiting for a person's approval or connection."""
146
+ if total_timeout_ms <= 0:
147
+ raise ValueError("total_timeout_ms must be positive")
148
+ return self._wait_async(bot_id, run_id, total_timeout_ms) if self.async_mode else self._wait_sync(bot_id, run_id, total_timeout_ms)
149
+
150
+ def _wait_sync(self, bot_id, run_id, total_timeout_ms):
151
+ deadline = time.monotonic() + total_timeout_ms / 1000
152
+ while time.monotonic() < deadline:
153
+ run = self.get_run(bot_id, run_id)
154
+ if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection"):
155
+ return run
156
+ time.sleep(min(1, max(0, deadline - time.monotonic())))
157
+ raise StacklinkError("Bot observation timed out. Continue observing the same run.", status_code=408, code="sdk_run_timeout", details={"botId": bot_id, "runId": run_id})
158
+
159
+ async def _wait_async(self, bot_id, run_id, total_timeout_ms):
160
+ deadline = time.monotonic() + total_timeout_ms / 1000
161
+ while time.monotonic() < deadline:
162
+ run = await self.get_run(bot_id, run_id)
163
+ if run["status"] in ("completed", "failed", "stopped", "cancelled", "waiting_for_approval", "waiting_for_connection"):
164
+ return run
165
+ await asyncio.sleep(min(1, max(0, deadline - time.monotonic())))
166
+ raise StacklinkError("Bot observation timed out. Continue observing the same run.", status_code=408, code="sdk_run_timeout", details={"botId": bot_id, "runId": run_id})
167
+
168
+ def events(self, bot_id: str, run_id: str, *, after: str = "0"):
169
+ return self.client.request("GET", self._path(bot_id, run_id) + "/events", query={"after": after})
170
+
171
+ def stream(self, bot_id: str, run_id: str, *, after: str = "0", total_timeout_ms: int = 300_000):
172
+ method = self._stream_async if self.async_mode else self._stream_sync
173
+ return method(bot_id, run_id, after=after, total_timeout_ms=total_timeout_ms)
174
+
175
+ def _stream_sync(self, bot_id, run_id, *, after, total_timeout_ms):
176
+ deadline = time.monotonic() + total_timeout_ms / 1000
177
+ cursor = after
178
+ while True:
179
+ remaining = _remaining(deadline, run_id, cursor)
180
+ try:
181
+ with closing(self.client.request_sse("GET", self._path(bot_id, run_id) + "/events/stream", headers={"Last-Event-ID": cursor}, total_timeout_ms=min(remaining, 30_000))) as stream:
182
+ for envelope in stream:
183
+ event = envelope.get("data")
184
+ if not isinstance(event, dict) or "sequence" not in event:
185
+ continue
186
+ _validate_event(event, run_id)
187
+ if int(event["sequence"]) <= int(cursor):
188
+ continue
189
+ cursor = event["sequence"]
190
+ yield event
191
+ if _terminal(event):
192
+ return
193
+ except (StacklinkError, httpx.TransportError) as error:
194
+ if not _retryable(error):
195
+ raise
196
+ page = self.events(bot_id, run_id, after=cursor)
197
+ for event in page["events"]:
198
+ _validate_event(event, run_id)
199
+ if int(event["sequence"]) <= int(cursor):
200
+ continue
201
+ cursor = event["sequence"]
202
+ yield event
203
+ if _terminal(event):
204
+ return
205
+ if page.get("terminal") and not page["events"]:
206
+ return
207
+ if len(page["events"]) < 200:
208
+ time.sleep(min(1, _remaining(deadline, run_id, cursor) / 1000))
209
+
210
+ async def _stream_async(self, bot_id, run_id, *, after, total_timeout_ms):
211
+ deadline = time.monotonic() + total_timeout_ms / 1000
212
+ cursor = after
213
+ while True:
214
+ remaining = _remaining(deadline, run_id, cursor)
215
+ try:
216
+ async with aclosing(self.client.request_sse("GET", self._path(bot_id, run_id) + "/events/stream", headers={"Last-Event-ID": cursor}, total_timeout_ms=min(remaining, 30_000))) as stream:
217
+ async for envelope in stream:
218
+ event = envelope.get("data")
219
+ if not isinstance(event, dict) or "sequence" not in event:
220
+ continue
221
+ _validate_event(event, run_id)
222
+ if int(event["sequence"]) <= int(cursor):
223
+ continue
224
+ cursor = event["sequence"]
225
+ yield event
226
+ if _terminal(event):
227
+ return
228
+ except (StacklinkError, httpx.TransportError) as error:
229
+ if not _retryable(error):
230
+ raise
231
+ page = await self.events(bot_id, run_id, after=cursor)
232
+ for event in page["events"]:
233
+ _validate_event(event, run_id)
234
+ if int(event["sequence"]) <= int(cursor):
235
+ continue
236
+ cursor = event["sequence"]
237
+ yield event
238
+ if _terminal(event):
239
+ return
240
+ if page.get("terminal") and not page["events"]:
241
+ return
242
+ if len(page["events"]) < 200:
243
+ await asyncio.sleep(min(1, _remaining(deadline, run_id, cursor) / 1000))
244
+
245
+ def cancel(self, bot_id: str, run_id: str):
246
+ return self.client.request("POST", self._path(bot_id, run_id) + "/stop", json_body={})
247
+
248
+
249
+ def _terminal(event):
250
+ return event.get("type") in ("run.completed", "run.failed", "run.stopped")
251
+
252
+
253
+ def _validate_event(event, run_id):
254
+ if event.get("runId") != run_id:
255
+ raise StacklinkError("Bot event belongs to a different run.", status_code=502, code="bot_event_invalid")
256
+
257
+
258
+ def _retryable(error):
259
+ return isinstance(error, httpx.TransportError) or (isinstance(error, StacklinkError) and error.status_code in (408, 429, 502, 503, 504) and error.code != "bot_event_invalid")
260
+
261
+
262
+ def _remaining(deadline, run_id, cursor):
263
+ remaining = int((deadline - time.monotonic()) * 1000)
264
+ if remaining <= 0:
265
+ raise StacklinkError("Bot observation timed out. Resume the same run from the last sequence.", status_code=408, code="sdk_stream_timeout", details={"runId": run_id, "after": cursor})
266
+ return remaining
@@ -19,6 +19,8 @@ from .api_keys import ApiKeysResource
19
19
  from .artifacts import ArtifactsResource
20
20
  from .auth_configs import AuthConfigsResource
21
21
  from .billing import BillingResource
22
+ from .bots import BotsResource
23
+ from .approvals import ApprovalsResource
22
24
  from .connected_accounts import ConnectedAccountsResource
23
25
  from .errors import RateLimitError, StacklinkError
24
26
  from .executions import ExecutionsResource
@@ -35,7 +37,7 @@ from .types import Json, Transport
35
37
  from .webhooks import WebhooksResource
36
38
  from .workbench import WorkbenchResource
37
39
 
38
- STACKLINK_PYTHON_SDK_VERSION = "0.2.2"
40
+ STACKLINK_PYTHON_SDK_VERSION = "0.3.7"
39
41
  STACKLINK_PYTHON_USER_AGENT = f"stacklink-python/{STACKLINK_PYTHON_SDK_VERSION}"
40
42
 
41
43
 
@@ -52,6 +54,8 @@ class Stacklink:
52
54
  max_attempts: int = 3,
53
55
  http_client: httpx.Client | None = None,
54
56
  ):
57
+ self.bots = BotsResource(self)
58
+ self.approvals = ApprovalsResource(self)
55
59
  self.api_key = api_key or os.getenv("STACKLINK_API_KEY")
56
60
  if not self.api_key:
57
61
  raise StacklinkError("STACKLINK_API_KEY is required.")
@@ -420,6 +424,8 @@ class AsyncStacklink:
420
424
  max_attempts: int = 3,
421
425
  http_client: httpx.AsyncClient | None = None,
422
426
  ):
427
+ self.bots = BotsResource(self, async_mode=True)
428
+ self.approvals = ApprovalsResource(self, async_mode=True)
423
429
  self.api_key = api_key or os.getenv("STACKLINK_API_KEY")
424
430
  if not self.api_key:
425
431
  raise StacklinkError("STACKLINK_API_KEY is required.")
@@ -1,6 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
+ import base64
5
+ import hashlib
6
+ import hmac
7
+ import time
8
+
9
+
10
+ def verify_webhook_signature(*, body: str, secret: str, id: str, timestamp: str, signature: str, now: float | None = None, tolerance_seconds: int = 300) -> bool:
11
+ """Verify the original callback body and destination-specific signing secret."""
12
+ try:
13
+ seconds = int(timestamp)
14
+ except (TypeError, ValueError):
15
+ return False
16
+ if abs((time.time() if now is None else now) - seconds) > tolerance_seconds:
17
+ return False
18
+ expected = base64.b64encode(hmac.new(secret.encode("utf-8"), f"{id}.{timestamp}.{body}".encode("utf-8"), hashlib.sha256).digest()).decode("ascii")
19
+ for entry in signature.split(" "):
20
+ version, separator, candidate = entry.partition(",")
21
+ if separator and version == "v1" and hmac.compare_digest(candidate, expected):
22
+ return True
23
+ return False
4
24
 
5
25
 
6
26
  class WebhooksResource:
@@ -14,6 +14,7 @@ WorkbenchOutputMode = Literal["inline", "preview", "events", "artifact"]
14
14
  WorkbenchExecutionKind = Literal["workbench_code", "workbench_bash"]
15
15
  WorkbenchReplayEnvironmentMode = Literal["require_match", "restore_snapshot", "current"]
16
16
  WorkbenchExecutionStatus = Literal[
17
+ "waiting_for_approval",
17
18
  "queued",
18
19
  "started",
19
20
  "running",
@@ -459,6 +460,9 @@ class WorkbenchExecutionStateResponse(TypedDict, total=False):
459
460
 
460
461
 
461
462
  class WorkbenchExecutionResponse(TypedDict, total=False):
463
+ approvalRequestId: str
464
+ executed: bool
465
+ nextAction: Literal["wait_for_approval"]
462
466
  workbenchSession: WorkbenchSession
463
467
  executionKind: WorkbenchExecutionKind
464
468
  executionId: str
@@ -547,7 +551,8 @@ def _wait_for_workbench_response(
547
551
  execution_id = accepted.get("executionId")
548
552
  execution_kind = accepted.get("executionKind")
549
553
  if (
550
- not isinstance(session_id, str)
554
+ accepted.get("status") == "waiting_for_approval"
555
+ or not isinstance(session_id, str)
551
556
  or not isinstance(execution_id, str)
552
557
  or execution_kind not in {"workbench_code", "workbench_bash"}
553
558
  ):
@@ -562,6 +567,7 @@ def _wait_for_workbench_response(
562
567
  ))
563
568
  execution = waited.get("execution")
564
569
  if not isinstance(execution, dict) or execution.get("status") not in {
570
+ "waiting_for_approval",
565
571
  "completed",
566
572
  "failed",
567
573
  "cancelled",
@@ -576,6 +582,11 @@ def _wait_for_workbench_response(
576
582
  "execution": cast(WorkbenchExecutionLog, execution),
577
583
  "executionLog": cast(WorkbenchExecutionLog, execution),
578
584
  }
585
+ if isinstance(execution.get("approvalRequestId"), str):
586
+ response["approvalRequestId"] = execution["approvalRequestId"]
587
+ if execution.get("status") == "waiting_for_approval":
588
+ response["executed"] = False
589
+ response["nextAction"] = "wait_for_approval"
579
590
  output_mode = execution.get("outputMode")
580
591
  if output_mode in {"inline", "preview", "events", "artifact"}:
581
592
  response["outputMode"] = cast(WorkbenchOutputMode, output_mode)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stacklink-sdk
3
- Version: 0.2.2
3
+ Version: 0.3.7
4
4
  Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
5
5
  Author: Stacklink
6
6
  License-Expression: LicenseRef-Proprietary
@@ -77,8 +77,35 @@ An execution response can be pending. Keep its `executionKind`, `executionId`, s
77
77
 
78
78
  Retry keys are case-sensitive and accept up to 240 characters. Tool and batch requests have separate key namespaces. Reuse a key only for the exact same request in the same SDK session. Changed inputs return `409 idempotency_key_reused`; an expired stored result returns `410 execution_result_expired`. Neither response authorizes submitting the write again.
79
79
 
80
- For `waiting_for_approval`, your backend can use the session approval read, approve and deny APIs with `platform_approvals:read` / `platform_approvals:manage` scopes. Keep the API key on your backend. A pending action must not be labelled completed. `provider_execution_result_unknown` means an external write may have happened; inspect the provider before considering another action.
80
+ For `waiting_for_approval`, read the shared request using `client.approvals` and `platform_approvals:read`. A decision also needs `platform_approvals:manage` and the authority required by that request. A server key alone cannot approve on behalf of a company person; eligible customer confirmation uses the verified session and its scoped grant. Keep the API key on your backend. A pending action must not be labelled completed. `provider_execution_result_unknown` means an external write may have happened; inspect the provider before considering another action.
81
81
 
82
82
  Use execution cancellation for an explicit Stop request. Losing a stream only stops observation. Vercel and LangChain adapters forward their supported abort signal; other adapters keep the structured operation identity available for explicit cancellation. MCP progress reports actual stages, not invented percentages.
83
83
 
84
84
  Streaming has a total observation deadline and a separate inactivity timeout. A timeout raises `sdk_stream_timeout` without repeating the action; use its original operation ID to resume observation. Stream responses are limited to 8 MiB, including JSON responses to streaming requests. The synchronous client keeps blocking reads in a bounded reader and releases the response when that read finishes or reaches its inactivity timeout. Custom transports must honor the timeout passed to them. MCP calls return once all matching responses arrive, even if the server keeps the stream open.
85
+
86
+ ## Hosted Bots (0.3.2)
87
+
88
+ Bots are saved, published agents with project-scoped configuration and durable runs. Use a server-held project API key with `bots:manage` to create/update/publish, and `bots:run` to invoke. Supply a session established from your authenticated customer; input variables do not grant identity or access. Both SDKs expose run status, replayable events, cancellation and callback delivery recovery. A callback failure does not rerun the model.
89
+
90
+ ```python
91
+ bot = stacklink.bots.get(bot_id)
92
+ run = stacklink.bots.run(bot_id, session_id=session.id, message="Where is my order?", variables={"orderNumber": "4821"})
93
+ for event in stacklink.bots.stream(bot_id, run["id"]):
94
+ print(event)
95
+ result = stacklink.bots.get_run(bot_id, run["id"])
96
+ print(result["output"] or result["finalText"])
97
+ ```
98
+
99
+ The async client provides the same operations with `await` and `async for`.
100
+
101
+ Reuse the returned `conversationId` for the next message. Preserve the last event sequence to resume observation. Published invocations are available through the existing session MCP endpoint using `STACKLINK_BOTS`; no separate server per bot is needed.
102
+
103
+ ## Shared approvals (0.3.3)
104
+
105
+ `client.approvals` lists and retrieves approvals for an authorized session, submits a permitted decision or customer correction, and observes reconnectable approval events. A project key represents an application; it cannot substitute for a required human company approver. `as_customer=True` derives the customer from the authenticated session and still requires a scoped customer grant. Continue observing the original execution after deciding; do not resubmit the action.
106
+
107
+ All enabled notification channels use the same stored request. Decision, notification delivery and execution have separate states. See the [approval guide](https://docs.stacklink.in/docs/reference/sdk-python#shared-approvals) for request and lifecycle details.
108
+
109
+ When project settings require Workbench code approval, a foreground code/bash call returns `waiting_for_approval`, `approvalRequestId`, and its original execution ID immediately. No runtime sandbox is allocated for that wait. After the authorized person decides, observe the original execution; do not submit the code again.
110
+
111
+ Server-side customer grant management (0.3.5): `get_customer_grant`, `save_customer_grant`, and `remove_customer_grant` operate on an authenticated customer session. Save exact tool keys and resource restrictions with the returned settings revision. The server supplies identity from the session; callers cannot choose another customer in the request body. These methods never grant company approval or bypass connected-account access.
@@ -3,9 +3,11 @@ README.md
3
3
  pyproject.toml
4
4
  stacklink/__init__.py
5
5
  stacklink/api_keys.py
6
+ stacklink/approvals.py
6
7
  stacklink/artifacts.py
7
8
  stacklink/auth_configs.py
8
9
  stacklink/billing.py
10
+ stacklink/bots.py
9
11
  stacklink/claude_agent.py
10
12
  stacklink/client.py
11
13
  stacklink/connected_accounts.py
@@ -14,6 +14,7 @@ import httpx
14
14
  from stacklink import (
15
15
  AsyncStacklink,
16
16
  RateLimitError,
17
+ STACKLINK_PYTHON_SDK_VERSION,
17
18
  Stacklink,
18
19
  StacklinkError,
19
20
  create_stacklink_claude_agent_tool_config,
@@ -60,6 +61,103 @@ def stalled_stream_server(*, final_result=False):
60
61
 
61
62
 
62
63
  class StacklinkClientTests(unittest.TestCase):
64
+ def test_customer_grant_management_uses_session_identity(self):
65
+ calls = []
66
+ def transport(method, path, headers, body, query, timeout):
67
+ calls.append((method, path, body, query))
68
+ return 200, {}, {'revision': len(calls), 'grant': None}
69
+ client = Stacklink(api_key='ppk_test', transport=transport)
70
+ client.approvals.get_customer_grant(session_id='customer-session')
71
+ grant = {'revision': 1, 'name': 'Customer', 'toolKeys': ['calendar.move'], 'accountIds': [], 'resources': [{'pointer': '/appointmentId', 'value': 'appointment-1'}]}
72
+ client.approvals.save_customer_grant(grant, session_id='customer-session')
73
+ client.approvals.remove_customer_grant(session_id='customer-session', revision=2)
74
+ self.assertEqual([call[0] for call in calls], ['GET', 'PUT', 'DELETE'])
75
+ self.assertTrue(all(call[1] == '/v1/approvals/customer-grant' and call[3] == {'sessionId': 'customer-session'} for call in calls))
76
+ self.assertEqual(calls[1][2], grant)
77
+ self.assertEqual(calls[2][2], {'revision': 2})
78
+
79
+
80
+ def test_workbench_approval_returns_to_caller_before_decision(self):
81
+ for operation in ("run_code", "bash"):
82
+ for admission_status in ("waiting_for_approval", "queued"):
83
+ calls = []
84
+ waiting = {"id": "execution-1", "executionId": "execution-1", "executionKind": "workbench_bash" if operation == "bash" else "workbench_code", "status": "waiting_for_approval", "approvalRequestId": "approval-1"}
85
+ def transport(method, path, headers, body, query, timeout):
86
+ calls.append(path)
87
+ if path in {"/v1/workbench/run", "/v1/workbench/bash"}:
88
+ return 202, {}, {**waiting, "status": admission_status, "workbenchSession": {"id": "workspace-1"}, "executed": False, "nextAction": "wait_for_approval"}
89
+ if path.endswith("/wait") and len(calls) == 2:
90
+ return 200, {}, {"execution": waiting}
91
+ raise AssertionError(f"Unexpected polling during approval: {path}")
92
+ client = Stacklink(api_key="ppk_test", transport=transport)
93
+ payload = {"sessionId": "session-1", "workbenchSessionId": "workspace-1", "operation": operation, "language": "python", "code": "print(1)", "command": "echo 1"}
94
+ result = client.workbench.bash(payload) if operation == "bash" else client.workbench.run(payload)
95
+ self.assertEqual(result["status"], "waiting_for_approval")
96
+ self.assertEqual(result["approvalRequestId"], "approval-1")
97
+ self.assertFalse(result["executed"])
98
+ self.assertEqual(len(calls), 2 if admission_status == "queued" else 1)
99
+
100
+ def test_approvals_observe_reconnect_and_customer_identity(self):
101
+ calls = []
102
+ def transport(method, path, headers, body, query, timeout):
103
+ calls.append((method, path, body, query))
104
+ if path.endswith('/events'):
105
+ cursor = str(int(query['after']) + 1)
106
+ return 200, {}, {'events': [{'sequence': cursor, 'approvalId': 'approval-one', 'type': 'approval.request.updated', 'data': {'status': 'pending'}, 'occurredAt': '2026-10-14T00:00:00Z'}], 'nextCursor': cursor}
107
+ return 409, {}, {'code': 'approval_revision_conflict', 'message': 'Review current values.'}
108
+ client = Stacklink(api_key='ppk_test', base_url='https://stacklink.test', transport=transport)
109
+ with self.assertRaises(StacklinkError):
110
+ client.approvals.decide('approval-one', {'requirement': 'customer', 'decision': 'approve', 'expectedRevision': 2}, session_id='session-one', as_customer=True)
111
+ self.assertEqual(len(calls), 1)
112
+ self.assertEqual(calls[0][3]['asCustomer'], 'true')
113
+ events = client.approvals.observe(session_id='session-one', after='8', as_customer=True)
114
+ self.assertEqual(next(events)['sequence'], '9')
115
+ events.close()
116
+ events = client.approvals.observe(session_id='session-one', after='9', as_customer=True)
117
+ self.assertEqual(next(events)['sequence'], '10')
118
+ events.close()
119
+ self.assertEqual([call[3]['after'] for call in calls[1:]], ['8', '9'])
120
+ self.assertTrue(all('/v1/approvals/' in call[1] for call in calls))
121
+
122
+ def test_bot_trigger_signature_binds_original_unicode_bytes(self):
123
+ import hashlib, hmac
124
+ from stacklink import sign_bot_trigger_request
125
+ body = '{"message":"नमस्ते"}'
126
+ headers = sign_bot_trigger_request(trigger_id="trigger-one", secret="fixture", body=body, request_id="event-one", now=1000)
127
+ signed = json.dumps(["stacklink-trigger-v1", "trigger-one", "1000", "event-one", hashlib.sha256(body.encode()).hexdigest()], separators=(",", ":"))
128
+ self.assertEqual(headers["stacklink-signature"], hmac.new(b"fixture", signed.encode(), hashlib.sha256).hexdigest())
129
+ self.assertNotEqual(headers["stacklink-signature"], sign_bot_trigger_request(trigger_id="trigger-one", secret="fixture", body=body + " ", request_id="event-one", now=1000)["stacklink-signature"])
130
+
131
+ def test_bot_management_uses_shared_routes_and_exact_customer_session(self):
132
+ calls = []
133
+ def transport(method, path, headers, body, query, timeout):
134
+ calls.append((method, path, body))
135
+ return 200, {}, {"ok": True}
136
+ client = Stacklink(api_key="ppk_test", base_url="http://stacklink.test", transport=transport)
137
+ client.bots.trigger_webhook("bot-one", "trigger-one")
138
+ client.bots.trigger_admission("bot-one", "trigger-one", "admission-one")
139
+ client.bots.delete_customer_data("bot-one", "session-alice")
140
+ client.bots.api_signing_key("bot-one", "api-one", action="prepare", overlap_seconds=86400)
141
+ client.bots.api_signing_key("bot-one", "api-one", action="activate", key_id="key-two")
142
+ self.assertEqual(calls, [
143
+ ("POST", "/v1/bots/bot-one/triggers/trigger-one/webhook", {}),
144
+ ("GET", "/v1/bots/bot-one/triggers/trigger-one/admissions/admission-one", None),
145
+ ("POST", "/v1/bots/bot-one/customer-data/delete", {"sessionId": "session-alice"}),
146
+ ("POST", "/v1/bots/bot-one/apis/api-one/signing-key", {"action": "prepare", "overlapSeconds": 86400}),
147
+ ("POST", "/v1/bots/bot-one/apis/api-one/signing-key", {"action": "activate", "keyId": "key-two"}),
148
+ ])
149
+
150
+ def test_callback_signature_uses_original_body_and_expiry(self):
151
+ import base64, hashlib, hmac
152
+ from stacklink import verify_webhook_signature
153
+ body = '{"message":"नमस्ते"}'
154
+ signature = "v1," + base64.b64encode(hmac.new(b"test-secret", f"delivery.1000.{body}".encode("utf-8"), hashlib.sha256).digest()).decode("ascii")
155
+ values = dict(body=body, secret="test-secret", id="delivery", timestamp="1000", signature=signature, now=1001)
156
+ self.assertTrue(verify_webhook_signature(**values))
157
+ self.assertFalse(verify_webhook_signature(**{**values, "body": "{}"}))
158
+ self.assertFalse(verify_webhook_signature(**{**values, "now": 1301}))
159
+ self.assertFalse(verify_webhook_signature(**{**values, "secret": "wrong"}))
160
+
63
161
  def test_session_create_uses_env_contract_and_auth_header(self):
64
162
  calls = []
65
163
 
@@ -83,8 +181,8 @@ class StacklinkClientTests(unittest.TestCase):
83
181
  self.assertEqual(headers["authorization"], "Bearer ppk_test")
84
182
  self.assertEqual(headers["x-stacklink-client-channel"], "python_sdk")
85
183
  self.assertEqual(headers["x-stacklink-client-name"], "stacklink-python")
86
- self.assertEqual(headers["x-stacklink-client-version"], "0.2.2")
87
- self.assertEqual(headers["user-agent"], "stacklink-python/0.2.2")
184
+ self.assertEqual(headers["x-stacklink-client-version"], STACKLINK_PYTHON_SDK_VERSION)
185
+ self.assertEqual(headers["user-agent"], f"stacklink-python/{STACKLINK_PYTHON_SDK_VERSION}")
88
186
  self.assertEqual(body, {"externalTenantId": "tenant_1"})
89
187
  self.assertEqual(query, {})
90
188
  self.assertAlmostEqual(timeout, 15, places=3)
@@ -1502,3 +1600,46 @@ class StacklinkClientTests(unittest.TestCase):
1502
1600
 
1503
1601
  if __name__ == "__main__":
1504
1602
  unittest.main()
1603
+
1604
+
1605
+ class BotStreamRecoveryTests(unittest.TestCase):
1606
+ def test_bot_wait_returns_approval_without_resubmitting(self):
1607
+ calls = []
1608
+ waiting = {"id": "run-one", "status": "waiting_for_approval", "waitingOperation": {"id": "execution-one"}}
1609
+ def transport(method, path, headers, body, query, timeout):
1610
+ calls.append((method, path))
1611
+ return 200, {}, waiting
1612
+ client = Stacklink(api_key="test", transport=transport)
1613
+ self.assertEqual(client.bots.wait("bot-one", "run-one"), waiting)
1614
+ self.assertEqual(calls, [("GET", "/v1/bots/bot-one/runs/run-one")])
1615
+
1616
+ def test_sync_bot_replays_missed_terminal_event(self):
1617
+ calls = []
1618
+ delta = {"runId": "run-one", "sequence": "1", "type": "model.text.delta", "payload": {"text": "नमस्ते"}}
1619
+ done = {"runId": "run-one", "sequence": "2", "type": "run.completed", "payload": {"finalText": "नमस्ते"}}
1620
+ def transport(method, path, headers, body, query, timeout):
1621
+ calls.append((method, path, query))
1622
+ if path.endswith("/stream"):
1623
+ return 200, {"content-type": "text/event-stream"}, "data: " + json.dumps(delta) + "\n\n"
1624
+ self.assertEqual(query, {"after": "1"})
1625
+ return 200, {"content-type": "application/json"}, {"terminal": True, "events": [done]}
1626
+ client = Stacklink(api_key="test", transport=transport)
1627
+ self.assertEqual(list(client.bots.stream("bot-one", "run-one")), [delta, done])
1628
+ self.assertEqual(len(calls), 2)
1629
+
1630
+ def test_async_bot_replays_missed_terminal_event(self):
1631
+ async def proof():
1632
+ calls = []
1633
+ delta = {"runId": "run-one", "sequence": "1", "type": "model.text.delta", "payload": {"text": "नमस्ते"}}
1634
+ done = {"runId": "run-one", "sequence": "2", "type": "run.completed", "payload": {"finalText": "नमस्ते"}}
1635
+ async def handler(request):
1636
+ calls.append(request.url.path)
1637
+ if request.url.path.endswith("/stream"):
1638
+ return httpx.Response(200, headers={"content-type": "text/event-stream"}, text="data: " + json.dumps(delta) + "\n\n")
1639
+ self.assertEqual(request.url.params["after"], "1")
1640
+ return httpx.Response(200, json={"terminal": True, "events": [done]})
1641
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
1642
+ async with AsyncStacklink(api_key="test", base_url="https://stacklink.test", http_client=http) as client:
1643
+ self.assertEqual([event async for event in client.bots.stream("bot-one", "run-one")], [delta, done])
1644
+ self.assertEqual(len(calls), 2)
1645
+ asyncio.run(proof())
File without changes
File without changes