getstack 1.2.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.
- {getstack-1.2.0 → getstack-1.3.0}/PKG-INFO +1 -1
- {getstack-1.2.0 → getstack-1.3.0}/pyproject.toml +1 -1
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/__init__.py +4 -1
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/passports.py +5 -1
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/proxy.py +41 -10
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_authority_request.py +6 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/passports.py +9 -1
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/proxy.py +71 -12
- {getstack-1.2.0 → getstack-1.3.0}/tests/test_authority.py +140 -0
- {getstack-1.2.0 → getstack-1.3.0}/.gitignore +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/CLAUDE.md +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/LICENSE +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/README.md +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/scripts/verify_sign_claim_crosslang.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/__init__.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/agents.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/audit.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/authority.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/credentials.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/delivery_methods.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/detector_config.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/dropoffs.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/evidence_packs.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/identity.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/inbound_webhooks.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/intents.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/llm.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/missions.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/notifications.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/partner_connect.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/partner_tenants.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/passport_session.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/pii_config.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/reviews.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/scan.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/security_events.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/services.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/skills.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/team.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/_async/usage.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/agent_auth.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/agents.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/audit.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/auth.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/authority.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/browser_bootstrap.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/client.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/credentials.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/delivery_methods.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/detector_config.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/dropoffs.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/errors.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/evidence_packs.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/identity.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/inbound_webhooks.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/intents.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/llm.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/missions.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/notifications.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/partner_connect.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/partner_tenants.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/passport_header_verify.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/passport_session.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/pii_config.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/py.typed +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/reviews.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/scan.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/security_events.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/services.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/sign_claim.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/skills.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/team.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/types.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/usage.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/src/getstack/verify_offline.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/tests/test_delivery_methods.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/tests/test_dropoffs.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/tests/test_errors.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/tests/test_evidence_packs.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/tests/test_identity_requirements.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/tests/test_inbound_webhooks.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/tests/test_missions.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/tests/test_parity.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/tests/test_partner_signing.py +0 -0
- {getstack-1.2.0 → getstack-1.3.0}/tests/test_sign_claim.py +0 -0
|
@@ -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",
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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:
|
|
@@ -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__(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|