getstack 1.1.0__tar.gz → 1.3.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 (85) hide show
  1. {getstack-1.1.0 → getstack-1.3.0}/PKG-INFO +1 -1
  2. {getstack-1.1.0 → getstack-1.3.0}/pyproject.toml +1 -1
  3. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/__init__.py +4 -1
  4. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/inbound_webhooks.py +1 -1
  5. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/passports.py +5 -1
  6. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/proxy.py +41 -10
  7. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_authority_request.py +6 -0
  8. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/inbound_webhooks.py +7 -6
  9. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/passports.py +9 -1
  10. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/proxy.py +71 -12
  11. {getstack-1.1.0 → getstack-1.3.0}/tests/test_authority.py +140 -0
  12. getstack-1.3.0/tests/test_inbound_webhooks.py +51 -0
  13. {getstack-1.1.0 → getstack-1.3.0}/.gitignore +0 -0
  14. {getstack-1.1.0 → getstack-1.3.0}/CLAUDE.md +0 -0
  15. {getstack-1.1.0 → getstack-1.3.0}/LICENSE +0 -0
  16. {getstack-1.1.0 → getstack-1.3.0}/README.md +0 -0
  17. {getstack-1.1.0 → getstack-1.3.0}/scripts/verify_sign_claim_crosslang.py +0 -0
  18. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/__init__.py +0 -0
  19. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/agents.py +0 -0
  20. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/audit.py +0 -0
  21. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/authority.py +0 -0
  22. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/credentials.py +0 -0
  23. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/delivery_methods.py +0 -0
  24. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/detector_config.py +0 -0
  25. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/dropoffs.py +0 -0
  26. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/evidence_packs.py +0 -0
  27. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/identity.py +0 -0
  28. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/intents.py +0 -0
  29. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/llm.py +0 -0
  30. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/missions.py +0 -0
  31. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/notifications.py +0 -0
  32. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/partner_connect.py +0 -0
  33. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/partner_tenants.py +0 -0
  34. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/passport_session.py +0 -0
  35. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/pii_config.py +0 -0
  36. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/reviews.py +0 -0
  37. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/scan.py +0 -0
  38. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/security_events.py +0 -0
  39. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/services.py +0 -0
  40. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/skills.py +0 -0
  41. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/team.py +0 -0
  42. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/_async/usage.py +0 -0
  43. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/agent_auth.py +0 -0
  44. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/agents.py +0 -0
  45. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/audit.py +0 -0
  46. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/auth.py +0 -0
  47. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/authority.py +0 -0
  48. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/browser_bootstrap.py +0 -0
  49. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/client.py +0 -0
  50. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/credentials.py +0 -0
  51. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/delivery_methods.py +0 -0
  52. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/detector_config.py +0 -0
  53. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/dropoffs.py +0 -0
  54. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/errors.py +0 -0
  55. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/evidence_packs.py +0 -0
  56. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/identity.py +0 -0
  57. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/intents.py +0 -0
  58. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/llm.py +0 -0
  59. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/missions.py +0 -0
  60. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/notifications.py +0 -0
  61. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/partner_connect.py +0 -0
  62. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/partner_tenants.py +0 -0
  63. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/passport_header_verify.py +0 -0
  64. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/passport_session.py +0 -0
  65. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/pii_config.py +0 -0
  66. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/py.typed +0 -0
  67. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/reviews.py +0 -0
  68. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/scan.py +0 -0
  69. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/security_events.py +0 -0
  70. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/services.py +0 -0
  71. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/sign_claim.py +0 -0
  72. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/skills.py +0 -0
  73. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/team.py +0 -0
  74. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/types.py +0 -0
  75. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/usage.py +0 -0
  76. {getstack-1.1.0 → getstack-1.3.0}/src/getstack/verify_offline.py +0 -0
  77. {getstack-1.1.0 → getstack-1.3.0}/tests/test_delivery_methods.py +0 -0
  78. {getstack-1.1.0 → getstack-1.3.0}/tests/test_dropoffs.py +0 -0
  79. {getstack-1.1.0 → getstack-1.3.0}/tests/test_errors.py +0 -0
  80. {getstack-1.1.0 → getstack-1.3.0}/tests/test_evidence_packs.py +0 -0
  81. {getstack-1.1.0 → getstack-1.3.0}/tests/test_identity_requirements.py +0 -0
  82. {getstack-1.1.0 → getstack-1.3.0}/tests/test_missions.py +0 -0
  83. {getstack-1.1.0 → getstack-1.3.0}/tests/test_parity.py +0 -0
  84. {getstack-1.1.0 → getstack-1.3.0}/tests/test_partner_signing.py +0 -0
  85. {getstack-1.1.0 → getstack-1.3.0}/tests/test_sign_claim.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: getstack
3
- Version: 1.1.0
3
+ Version: 1.3.0
4
4
  Summary: Python SDK for STACK — trust infrastructure for AI agents
5
5
  Project-URL: Homepage, https://getstack.run
6
6
  Project-URL: Documentation, https://getstack.run/docs/sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "getstack"
7
- version = "1.1.0"
7
+ version = "1.3.0"
8
8
  description = "Python SDK for STACK — trust infrastructure for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -47,7 +47,8 @@ from .dropoffs import DropoffService
47
47
  from .reviews import ReviewService
48
48
  from .notifications import NotificationService
49
49
  from .audit import AuditService
50
- from .proxy import ProxyService as ProxySvc, ProxyResponse
50
+ from .proxy import ProxyOperation, ProxyService as ProxySvc, ProxyResponse
51
+ from ._authority_request import create_authority_request_id
51
52
  from .scan import ScanService
52
53
  from .usage import UsageService
53
54
  from .security_events import SecurityEventService as SecurityEventSvc
@@ -169,6 +170,8 @@ __all__ = [
169
170
  "Mission",
170
171
  "ToolCall",
171
172
  "ProxyResponse",
173
+ "create_authority_request_id",
174
+ "ProxyOperation",
172
175
  "Skill",
173
176
  "SkillInvocation",
174
177
  "SkillRequest",
@@ -33,7 +33,7 @@ class AsyncInboundWebhookService:
33
33
  return list(res.get("webhooks", []))
34
34
 
35
35
  async def set_provider_signing_secret(self, webhook_id: str, signing_secret: str, downstream_signing_secret: Optional[str] = None) -> dict[str, Any]:
36
- """Human/admin setup: generic 32-256 byte key, or AgentMail whsec_ key and optional downstream key. Keys are never returned."""
36
+ """Configure the incoming provider key. If a generic downstream key is omitted, STACK generates and returns its replacement once."""
37
37
  from urllib.parse import quote
38
38
  return await self._client.put(
39
39
  f"/v1/operator/inbound-webhooks/{quote(webhook_id, safe='')}/provider-signing-secret",
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import asyncio
13
13
  from contextlib import asynccontextmanager
14
- from typing import Any, AsyncGenerator
14
+ from typing import Any, AsyncGenerator, Awaitable, Callable
15
15
 
16
16
  from ..client import AsyncHttpClient
17
17
  from ..passports import _parse_duration, _to_passport
@@ -85,6 +85,8 @@ class AsyncMission:
85
85
  body: Any = None,
86
86
  query: dict[str, str] | None = None,
87
87
  approval_id: str | None = None,
88
+ authority_request_id: str | None = None,
89
+ on_operation_id: Callable[[str], None | Awaitable[None]] | None = None,
88
90
  _proxy_service: AsyncProxyService | None = None,
89
91
  ) -> ProxyResponse:
90
92
  """Async proxy call using this mission's passport.
@@ -103,6 +105,8 @@ class AsyncMission:
103
105
  headers=headers, body=body, query=query,
104
106
  passport_token=self.token,
105
107
  approval_id=approval_id,
108
+ authority_request_id=authority_request_id,
109
+ on_operation_id=on_operation_id,
106
110
  )
107
111
  self.log(service, method, url)
108
112
  return resp
@@ -2,11 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any
5
+ import inspect
6
+ from typing import Any, Awaitable, Callable
6
7
 
7
- from .._authority_request import authority_request_headers
8
+ from .._authority_request import authority_request_headers, create_authority_request_id
8
9
  from ..client import AsyncHttpClient
9
- from ..proxy import ProxyResponse
10
+ from ..proxy import ProxyOperation, ProxyResponse
10
11
 
11
12
 
12
13
  class AsyncProxyService:
@@ -25,13 +26,22 @@ class AsyncProxyService:
25
26
  passport_token: str | None = None,
26
27
  approval_id: str | None = None,
27
28
  authority_request_id: str | None = None,
29
+ on_operation_id: Callable[[str], None | Awaitable[None]] | None = None,
28
30
  ) -> ProxyResponse:
31
+ operation_id = authority_request_id
32
+ if operation_id is None and method.upper() != "GET":
33
+ operation_id = create_authority_request_id()
34
+ if operation_id is not None and on_operation_id is not None:
35
+ persisted = on_operation_id(operation_id)
36
+ if inspect.isawaitable(persisted):
37
+ await persisted
38
+
29
39
  extra_headers: dict[str, str] = {}
30
40
  if passport_token:
31
41
  extra_headers["X-Passport-Token"] = passport_token
32
42
  if approval_id:
33
43
  extra_headers["X-Intent-Approval-Id"] = approval_id
34
- extra_headers.update(authority_request_headers(authority_request_id))
44
+ extra_headers.update(authority_request_headers(operation_id))
35
45
 
36
46
  payload: dict[str, Any] = {
37
47
  "service": service,
@@ -45,16 +55,37 @@ class AsyncProxyService:
45
55
  if query:
46
56
  payload["query"] = query
47
57
 
48
- data = await self._client.request(
49
- "POST",
50
- "/v1/proxy",
51
- json=payload,
52
- extra_headers=extra_headers if extra_headers else None,
53
- )
58
+ try:
59
+ data = await self._client.request(
60
+ "POST",
61
+ "/v1/proxy",
62
+ json=payload,
63
+ extra_headers=extra_headers if extra_headers else None,
64
+ )
65
+ except Exception as error:
66
+ if operation_id is not None:
67
+ setattr(error, "operation_id", operation_id)
68
+ raise
54
69
  return ProxyResponse(
55
70
  status=data.get("status", 0),
56
71
  headers=data.get("headers", {}),
57
72
  body=data.get("body"),
73
+ operation_id=operation_id,
74
+ )
75
+
76
+ async def get_operation(
77
+ self,
78
+ operation_id: str,
79
+ *,
80
+ passport_token: str,
81
+ include_response: bool = False,
82
+ ) -> ProxyOperation:
83
+ authority_request_headers(operation_id)
84
+ return await self._client.request(
85
+ "POST",
86
+ "/v1/proxy/operations/get",
87
+ json={"operation_id": operation_id, "include_response": include_response},
88
+ extra_headers={"X-Passport-Token": passport_token},
58
89
  )
59
90
 
60
91
  async def get(self, service: str, url: str, **kwargs: Any) -> ProxyResponse:
@@ -3,11 +3,17 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
+ import secrets
6
7
 
7
8
  AUTHORITY_REQUEST_ID_HEADER = "X-STACK-Authority-Request-Id"
8
9
  _AUTHORITY_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{22,256}$")
9
10
 
10
11
 
12
+ def create_authority_request_id() -> str:
13
+ """Create a cryptographically random 24-character base64url handle."""
14
+ return secrets.token_urlsafe(18)
15
+
16
+
11
17
  def authority_request_headers(authority_request_id: str | None) -> dict[str, str]:
12
18
  """Build the authority request header after validating its wire format."""
13
19
  if authority_request_id is None:
@@ -1,7 +1,7 @@
1
1
  """Hosted inbound webhook surface (Phase 5.5).
2
2
 
3
- Operator creates a webhook row + receives a STACK-hosted URL + HMAC
4
- secret. Generic providers sign with this secret. AgentMail endpoints
3
+ Operator creates a webhook row + receives a STACK-hosted URL and distinct
4
+ incoming/downstream HMAC secrets. Generic providers sign with the incoming secret. AgentMail endpoints
5
5
  require their provider signing secret through set_provider_signing_secret.
6
6
  STACK runs the prompt-injection detector on inbound content
7
7
  before forwarding to a downstream URL.
@@ -32,9 +32,10 @@ class InboundWebhookService:
32
32
  """Create a hosted inbound webhook.
33
33
 
34
34
  Returns a dict with keys: id, provider, slug, webhook_url,
35
- webhook_secret (plaintext, returned ONLY at create), agent_id,
36
- forward_url, created_at. Capture webhook_secret immediately —
37
- STACK never returns it again. If lost, revoke + recreate.
35
+ webhook_secret (downstream verification key, returned ONLY at create),
36
+ incoming_signing_secret (generic only, returned ONLY at create), agent_id,
37
+ forward_url, created_at. Capture both immediately. STACK never returns
38
+ them through list/get. If lost, revoke + recreate.
38
39
  """
39
40
  body: dict[str, Any] = {"provider": provider, "agent_id": agent_id}
40
41
  if forward_url is not None:
@@ -49,7 +50,7 @@ class InboundWebhookService:
49
50
  return list(res.get("webhooks", []))
50
51
 
51
52
  def set_provider_signing_secret(self, webhook_id: str, signing_secret: str, downstream_signing_secret: Optional[str] = None) -> dict[str, Any]:
52
- """Human/admin setup: generic 32-256 byte key, or AgentMail whsec_ key and optional downstream key. Keys are never returned."""
53
+ """Configure the incoming provider key. If a generic downstream key is omitted, STACK generates and returns its replacement once."""
53
54
  from urllib.parse import quote
54
55
  return self._client.put(
55
56
  f"/v1/operator/inbound-webhooks/{quote(webhook_id, safe='')}/provider-signing-secret",
@@ -6,7 +6,7 @@ import re
6
6
  import time
7
7
  import threading
8
8
  from contextlib import contextmanager
9
- from typing import Any, Generator
9
+ from typing import Any, Callable, Generator
10
10
 
11
11
  from .client import HttpClient
12
12
  from .proxy import ProxyService, ProxyResponse
@@ -101,6 +101,8 @@ class Mission:
101
101
  body: Any = None,
102
102
  query: dict[str, str] | None = None,
103
103
  approval_id: str | None = None,
104
+ authority_request_id: str | None = None,
105
+ on_operation_id: Callable[[str], None] | None = None,
104
106
  _proxy_service: ProxyService | None = None,
105
107
  ) -> ProxyResponse:
106
108
  """Send a request through STACK's credential proxy using this mission's passport.
@@ -117,6 +119,10 @@ class Mission:
117
119
  is in enforced mode. Pass the approval id returned
118
120
  from ``final.id`` after ``submit_and_wait`` reports
119
121
  ``status='approved'``.
122
+ authority_request_id: Stable operation identifier to reuse only
123
+ for an unchanged retry.
124
+ on_operation_id: Called before network I/O so a durable job can
125
+ persist the generated operation identifier.
120
126
  _proxy_service: ProxyService instance (falls back to self._proxy).
121
127
  """
122
128
  ps = _proxy_service or getattr(self, "_proxy", None)
@@ -129,6 +135,8 @@ class Mission:
129
135
  headers=headers, body=body, query=query,
130
136
  passport_token=self.token,
131
137
  approval_id=approval_id,
138
+ authority_request_id=authority_request_id,
139
+ on_operation_id=on_operation_id,
132
140
  )
133
141
  self.log(service, method, url)
134
142
  return resp
@@ -2,21 +2,28 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any
5
+ from typing import Any, Callable, Literal, TypedDict
6
6
 
7
- from ._authority_request import authority_request_headers
7
+ from ._authority_request import authority_request_headers, create_authority_request_id
8
8
  from .client import HttpClient
9
9
 
10
10
 
11
11
  class ProxyResponse:
12
12
  """Response from a proxied request."""
13
13
 
14
- __slots__ = ("status", "headers", "body")
14
+ __slots__ = ("status", "headers", "body", "operation_id")
15
15
 
16
- def __init__(self, status: int, headers: dict[str, str], body: Any):
16
+ def __init__(
17
+ self,
18
+ status: int,
19
+ headers: dict[str, str],
20
+ body: Any,
21
+ operation_id: str | None = None,
22
+ ):
17
23
  self.status = status
18
24
  self.headers = headers
19
25
  self.body = body
26
+ self.operation_id = operation_id
20
27
 
21
28
  def ok(self) -> bool:
22
29
  return 200 <= self.status < 400
@@ -28,6 +35,25 @@ class ProxyResponse:
28
35
  return f"ProxyResponse(status={self.status})"
29
36
 
30
37
 
38
+ class ProxyOperation(TypedDict, total=False):
39
+ """Durable delivery, replay, and accounting state for one proxy write."""
40
+
41
+ operation_id: str
42
+ delivery_state: Literal["admitted", "dispatching", "response_received", "outcome_unknown"]
43
+ replay_state: Literal["none", "screening", "available", "expired", "erased"]
44
+ accounting_state: Literal["reserved", "committed", "released"]
45
+ provider: str
46
+ method: Literal["POST", "PUT", "PATCH", "DELETE"]
47
+ created_at: str
48
+ dispatched_at: str | None
49
+ response_received_at: str | None
50
+ provider_outcome: Literal[
51
+ "not_required", "pending", "confirmed", "not_found", "contradictory", "expired"
52
+ ]
53
+ provider_status: Literal["pending", "requires_action", "succeeded", "failed", "canceled"] | None
54
+ response: dict[str, Any]
55
+
56
+
31
57
  class ProxyService:
32
58
  def __init__(self, client: HttpClient):
33
59
  self._client = client
@@ -44,6 +70,7 @@ class ProxyService:
44
70
  passport_token: str | None = None,
45
71
  approval_id: str | None = None,
46
72
  authority_request_id: str | None = None,
73
+ on_operation_id: Callable[[str], None] | None = None,
47
74
  ) -> ProxyResponse:
48
75
  """Send a request through STACK's credential proxy.
49
76
 
@@ -59,14 +86,24 @@ class ProxyService:
59
86
  (Sub-chunk 2.4). Required when the calling passport is
60
87
  in enforced mode. Sent as ``X-Intent-Approval-Id``.
61
88
  authority_request_id: Stable, high-entropy request identifier for
62
- authority-bound replay and idempotency checks.
89
+ operation recovery. Non-GET calls generate one before network
90
+ I/O when omitted; persist ``response.operation_id`` and reuse
91
+ it only for an unchanged retry.
92
+ on_operation_id: Called before network I/O. Durable job runners
93
+ should persist the supplied identifier here.
63
94
  """
95
+ operation_id = authority_request_id
96
+ if operation_id is None and method.upper() != "GET":
97
+ operation_id = create_authority_request_id()
98
+ if operation_id is not None and on_operation_id is not None:
99
+ on_operation_id(operation_id)
100
+
64
101
  extra_headers: dict[str, str] = {}
65
102
  if passport_token:
66
103
  extra_headers["X-Passport-Token"] = passport_token
67
104
  if approval_id:
68
105
  extra_headers["X-Intent-Approval-Id"] = approval_id
69
- extra_headers.update(authority_request_headers(authority_request_id))
106
+ extra_headers.update(authority_request_headers(operation_id))
70
107
 
71
108
  payload: dict[str, Any] = {
72
109
  "service": service,
@@ -80,16 +117,38 @@ class ProxyService:
80
117
  if query:
81
118
  payload["query"] = query
82
119
 
83
- data = self._client.request(
84
- "POST",
85
- "/v1/proxy",
86
- json=payload,
87
- extra_headers=extra_headers if extra_headers else None,
88
- )
120
+ try:
121
+ data = self._client.request(
122
+ "POST",
123
+ "/v1/proxy",
124
+ json=payload,
125
+ extra_headers=extra_headers if extra_headers else None,
126
+ )
127
+ except Exception as error:
128
+ if operation_id is not None:
129
+ setattr(error, "operation_id", operation_id)
130
+ raise
89
131
  return ProxyResponse(
90
132
  status=data.get("status", 0),
91
133
  headers=data.get("headers", {}),
92
134
  body=data.get("body"),
135
+ operation_id=operation_id,
136
+ )
137
+
138
+ def get_operation(
139
+ self,
140
+ operation_id: str,
141
+ *,
142
+ passport_token: str,
143
+ include_response: bool = False,
144
+ ) -> ProxyOperation:
145
+ """Read operation status and optionally its stored screened response."""
146
+ authority_request_headers(operation_id)
147
+ return self._client.request(
148
+ "POST",
149
+ "/v1/proxy/operations/get",
150
+ json={"operation_id": operation_id, "include_response": include_response},
151
+ extra_headers={"X-Passport-Token": passport_token},
93
152
  )
94
153
 
95
154
  def get(self, service: str, url: str, **kwargs) -> ProxyResponse:
@@ -17,10 +17,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
17
17
  from getstack import AsyncStack, Stack # noqa: E402
18
18
  from getstack._async.authority import AsyncAuthorityService # noqa: E402
19
19
  from getstack._async.llm import AsyncLlmService # noqa: E402
20
+ from getstack._async.passports import AsyncMission # noqa: E402
20
21
  from getstack._async.proxy import AsyncProxyService # noqa: E402
21
22
  from getstack._async.skills import AsyncSkillService # noqa: E402
22
23
  from getstack.authority import AuthorityService # noqa: E402
23
24
  from getstack.llm import LlmService # noqa: E402
25
+ from getstack.passports import Mission # noqa: E402
24
26
  from getstack.proxy import ProxyService # noqa: E402
25
27
  from getstack.skills import SkillService # noqa: E402
26
28
 
@@ -250,6 +252,69 @@ def test_runtime_calls_thread_authority_request_id_sync():
250
252
  assert call["extra_headers"]["X-STACK-Authority-Request-Id"] == request_id
251
253
 
252
254
 
255
+ def test_proxy_generates_exposes_and_recovers_operation_id_sync():
256
+ persisted: list[str] = []
257
+
258
+ class PersistenceCheckingClient(_SyncCaptureClient):
259
+ def request(self, *args: Any, **kwargs: Any):
260
+ assert persisted
261
+ return super().request(*args, **kwargs)
262
+
263
+ client = PersistenceCheckingClient()
264
+ response = ProxyService(client).request( # type: ignore[arg-type]
265
+ "stripe", "https://api.stripe.test/v1/refunds", method="POST",
266
+ passport_token="passport", on_operation_id=persisted.append,
267
+ )
268
+ assert response.operation_id is not None
269
+ assert len(response.operation_id) >= 22
270
+ assert client.calls[0]["extra_headers"]["X-STACK-Authority-Request-Id"] == response.operation_id
271
+
272
+ result = ProxyService(client).get_operation( # type: ignore[arg-type]
273
+ response.operation_id, passport_token="passport", include_response=True
274
+ )
275
+ assert result == {"ok": True}
276
+ assert client.calls[1]["path"] == "/v1/proxy/operations/get"
277
+ assert client.calls[1]["json"] == {
278
+ "operation_id": response.operation_id,
279
+ "include_response": True,
280
+ }
281
+ assert client.calls[1]["extra_headers"] == {"X-Passport-Token": "passport"}
282
+
283
+
284
+ def test_mission_proxy_persists_operation_id_before_sync_request():
285
+ persisted: list[str] = []
286
+ client = _SyncCaptureClient()
287
+ mission = Mission( # type: ignore[arg-type]
288
+ SimpleNamespace(), "agt_1", "Create issue", ["github"]
289
+ )
290
+ mission.passport = SimpleNamespace(token="passport") # type: ignore[assignment]
291
+
292
+ response = mission.proxy(
293
+ "github",
294
+ "https://api.github.test/repos/acme/example/issues",
295
+ method="POST",
296
+ body={"title": "Typo"},
297
+ on_operation_id=persisted.append,
298
+ _proxy_service=ProxyService(client), # type: ignore[arg-type]
299
+ )
300
+
301
+ assert persisted == [response.operation_id]
302
+ assert client.calls[0]["extra_headers"]["X-STACK-Authority-Request-Id"] == response.operation_id
303
+
304
+
305
+ def test_proxy_preserves_generated_operation_id_on_error_sync():
306
+ class FailingClient(_SyncCaptureClient):
307
+ def request(self, *args: Any, **kwargs: Any):
308
+ raise RuntimeError("response lost")
309
+
310
+ with pytest.raises(RuntimeError) as raised:
311
+ ProxyService(FailingClient()).request( # type: ignore[arg-type]
312
+ "stripe", "https://api.stripe.test/v1/refunds", method="POST",
313
+ passport_token="passport",
314
+ )
315
+ assert len(raised.value.operation_id) >= 22 # type: ignore[attr-defined]
316
+
317
+
253
318
  def test_runtime_calls_thread_authority_request_id_async():
254
319
  async def run() -> None:
255
320
  request_id = "0123456789abcdefghijkl"
@@ -280,6 +345,81 @@ def test_runtime_calls_thread_authority_request_id_async():
280
345
  asyncio.run(run())
281
346
 
282
347
 
348
+ def test_proxy_generates_exposes_and_recovers_operation_id_async():
349
+ async def run() -> None:
350
+ persisted: list[str] = []
351
+
352
+ class PersistenceCheckingClient(_AsyncCaptureClient):
353
+ async def request(self, *args: Any, **kwargs: Any):
354
+ assert persisted
355
+ return await super().request(*args, **kwargs)
356
+
357
+ async def persist(operation_id: str) -> None:
358
+ await asyncio.sleep(0)
359
+ persisted.append(operation_id)
360
+
361
+ client = PersistenceCheckingClient()
362
+ response = await AsyncProxyService(client).request( # type: ignore[arg-type]
363
+ "stripe", "https://api.stripe.test/v1/refunds", method="POST",
364
+ passport_token="passport", on_operation_id=persist,
365
+ )
366
+ assert response.operation_id is not None
367
+ assert len(response.operation_id) >= 22
368
+ assert client.calls[0]["extra_headers"]["X-STACK-Authority-Request-Id"] == response.operation_id
369
+
370
+ result = await AsyncProxyService(client).get_operation( # type: ignore[arg-type]
371
+ response.operation_id, passport_token="passport", include_response=True
372
+ )
373
+ assert result == {"ok": True}
374
+ assert client.calls[1]["path"] == "/v1/proxy/operations/get"
375
+ assert client.calls[1]["json"]["operation_id"] == response.operation_id
376
+
377
+ asyncio.run(run())
378
+
379
+
380
+ def test_async_mission_proxy_persists_operation_id_before_request():
381
+ async def run() -> None:
382
+ persisted: list[str] = []
383
+ client = _AsyncCaptureClient()
384
+ mission = AsyncMission( # type: ignore[arg-type]
385
+ SimpleNamespace(), "agt_1", "Create issue", ["github"]
386
+ )
387
+ mission.passport = SimpleNamespace(token="passport") # type: ignore[assignment]
388
+
389
+ async def persist(operation_id: str) -> None:
390
+ persisted.append(operation_id)
391
+
392
+ response = await mission.proxy(
393
+ "github",
394
+ "https://api.github.test/repos/acme/example/issues",
395
+ method="POST",
396
+ body={"title": "Typo"},
397
+ on_operation_id=persist,
398
+ _proxy_service=AsyncProxyService(client), # type: ignore[arg-type]
399
+ )
400
+
401
+ assert persisted == [response.operation_id]
402
+ assert client.calls[0]["extra_headers"]["X-STACK-Authority-Request-Id"] == response.operation_id
403
+
404
+ asyncio.run(run())
405
+
406
+
407
+ def test_proxy_preserves_generated_operation_id_on_error_async():
408
+ class FailingClient(_AsyncCaptureClient):
409
+ async def request(self, *args: Any, **kwargs: Any):
410
+ raise RuntimeError("response lost")
411
+
412
+ async def run() -> None:
413
+ with pytest.raises(RuntimeError) as raised:
414
+ await AsyncProxyService(FailingClient()).request( # type: ignore[arg-type]
415
+ "stripe", "https://api.stripe.test/v1/refunds", method="POST",
416
+ passport_token="passport",
417
+ )
418
+ assert len(raised.value.operation_id) >= 22 # type: ignore[attr-defined]
419
+
420
+ asyncio.run(run())
421
+
422
+
283
423
  def test_runtime_calls_reject_malformed_authority_request_id_sync():
284
424
  client = _SyncCaptureClient()
285
425
 
@@ -0,0 +1,51 @@
1
+ """Inbound webhook directional-key wire contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
+
11
+ from getstack._async.inbound_webhooks import AsyncInboundWebhookService # noqa: E402
12
+ from getstack.inbound_webhooks import InboundWebhookService # noqa: E402
13
+
14
+
15
+ class CaptureClient:
16
+ def __init__(self) -> None:
17
+ self.path: str | None = None
18
+ self.body: dict | None = None
19
+
20
+ def put(self, path: str, json: dict | None = None):
21
+ self.path = path
22
+ self.body = json
23
+ return {"configured": True, "webhook_secret": "synthetic-generated-downstream"}
24
+
25
+
26
+ class AsyncCaptureClient(CaptureClient):
27
+ async def put(self, path: str, json: dict | None = None):
28
+ return super().put(path, json=json)
29
+
30
+
31
+ def test_sync_signing_setup_keeps_directional_keys_in_the_body() -> None:
32
+ client = CaptureClient()
33
+ result = InboundWebhookService(client).set_provider_signing_secret( # type: ignore[arg-type]
34
+ "iwh fixture", "synthetic-incoming", "synthetic-downstream"
35
+ )
36
+ assert client.path == "/v1/operator/inbound-webhooks/iwh%20fixture/provider-signing-secret"
37
+ assert client.body == {
38
+ "signing_secret": "synthetic-incoming",
39
+ "downstream_signing_secret": "synthetic-downstream",
40
+ }
41
+ assert result == {"configured": True, "webhook_secret": "synthetic-generated-downstream"}
42
+
43
+
44
+ def test_async_signing_setup_matches_sync() -> None:
45
+ client = AsyncCaptureClient()
46
+ result = asyncio.run(AsyncInboundWebhookService(client).set_provider_signing_secret( # type: ignore[arg-type]
47
+ "iwh fixture", "synthetic-incoming"
48
+ ))
49
+ assert client.path == "/v1/operator/inbound-webhooks/iwh%20fixture/provider-signing-secret"
50
+ assert client.body == {"signing_secret": "synthetic-incoming"}
51
+ assert result == {"configured": True, "webhook_secret": "synthetic-generated-downstream"}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes