getstack 1.3.0__tar.gz → 2.0.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.3.0 → getstack-2.0.0}/.gitignore +3 -0
- {getstack-1.3.0 → getstack-2.0.0}/PKG-INFO +44 -40
- {getstack-1.3.0 → getstack-2.0.0}/README.md +43 -39
- {getstack-1.3.0 → getstack-2.0.0}/pyproject.toml +1 -1
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/__init__.py +55 -36
- getstack-2.0.0/src/getstack/_async/fleets.py +82 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/missions.py +17 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/partner_tenants.py +17 -6
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/passports.py +7 -5
- getstack-2.0.0/src/getstack/_fleet_key.py +44 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/agent_auth.py +9 -1
- getstack-2.0.0/src/getstack/fleets.py +82 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/missions.py +17 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/partner_connect.py +3 -2
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/partner_tenants.py +33 -9
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/passports.py +27 -4
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/proxy.py +3 -1
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/scan.py +3 -1
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/usage.py +4 -0
- getstack-2.0.0/tests/test_fleets.py +94 -0
- {getstack-1.3.0 → getstack-2.0.0}/tests/test_parity.py +7 -1
- getstack-2.0.0/tests/test_partner_signing.py +69 -0
- getstack-1.3.0/tests/test_partner_signing.py +0 -36
- {getstack-1.3.0 → getstack-2.0.0}/CLAUDE.md +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/LICENSE +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/scripts/verify_sign_claim_crosslang.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/__init__.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/agents.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/audit.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/authority.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/credentials.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/delivery_methods.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/detector_config.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/dropoffs.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/evidence_packs.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/identity.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/inbound_webhooks.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/intents.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/llm.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/notifications.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/partner_connect.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/passport_session.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/pii_config.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/proxy.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/reviews.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/scan.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/security_events.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/services.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/skills.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/team.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/usage.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_authority_request.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/agents.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/audit.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/auth.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/authority.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/browser_bootstrap.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/client.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/credentials.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/delivery_methods.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/detector_config.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/dropoffs.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/errors.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/evidence_packs.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/identity.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/inbound_webhooks.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/intents.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/llm.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/notifications.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/passport_header_verify.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/passport_session.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/pii_config.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/py.typed +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/reviews.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/security_events.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/services.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/sign_claim.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/skills.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/team.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/types.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/src/getstack/verify_offline.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/tests/test_authority.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/tests/test_delivery_methods.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/tests/test_dropoffs.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/tests/test_errors.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/tests/test_evidence_packs.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/tests/test_identity_requirements.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/tests/test_inbound_webhooks.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/tests/test_missions.py +0 -0
- {getstack-1.3.0 → getstack-2.0.0}/tests/test_sign_claim.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: getstack
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.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
|
|
@@ -44,32 +44,44 @@ npx -y @getstackrun/cli auth login
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
```python
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
47
|
+
import hashlib
|
|
48
|
+
import json
|
|
49
|
+
from getstack.errors import StackError
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_fleet_mission_example(stack, request, provider_url="https://slack.com/api/chat.postMessage"):
|
|
53
|
+
started = stack.missions.start(request)
|
|
54
|
+
repeated = stack.missions.start(request)
|
|
55
|
+
assert repeated["mission_id"] == started["mission_id"]
|
|
56
|
+
operation_id = hashlib.sha256(json.dumps([started["mission_id"], "support-update"]).encode()).hexdigest()
|
|
57
|
+
response = stack.proxy.request("slack", provider_url, "POST",
|
|
58
|
+
body={"channel": "C123", "text": "Support update"},
|
|
59
|
+
passport_token=started["passport"]["token"], authority_request_id=operation_id)
|
|
60
|
+
assert response.status == 200
|
|
61
|
+
try:
|
|
62
|
+
stack.proxy.request("slack", provider_url, "POST", body={"channel": "OTHER", "text": "Support update"},
|
|
63
|
+
passport_token=started["passport"]["token"], authority_request_id=operation_id + "_denied")
|
|
64
|
+
except StackError as error:
|
|
65
|
+
assert error.status_code == 403
|
|
66
|
+
else:
|
|
67
|
+
raise AssertionError("Expected channel restriction")
|
|
68
|
+
before = stack.missions.get(started["mission_id"])
|
|
69
|
+
renewed = stack.missions.renew(started["mission_id"])
|
|
70
|
+
after = stack.missions.get(renewed["mission_id"])
|
|
71
|
+
assert renewed["mission_id"] == started["mission_id"]
|
|
72
|
+
assert after["fleet"]["limits"] == before["fleet"]["limits"]
|
|
73
|
+
assert after["fleet"]["usage"] == before["fleet"]["usage"]
|
|
74
|
+
completed = stack.missions.complete(started["mission_id"], output={"done": True})
|
|
75
|
+
assert completed["terminal_state"] == "completed_successful"
|
|
76
|
+
stopped = stack.missions.start({**request, "idempotency_key": request["idempotency_key"] + "-stop"})
|
|
77
|
+
stack.missions.revoke(stopped["mission_id"], reason="operator_initiated_clean")
|
|
78
|
+
try:
|
|
79
|
+
stack.missions.renew(stopped["mission_id"])
|
|
80
|
+
except StackError:
|
|
81
|
+
pass
|
|
82
|
+
else:
|
|
83
|
+
raise AssertionError("Expected revoked Mission")
|
|
84
|
+
return {"mission_id": started["mission_id"], "operation_id": operation_id, "completed": True, "revoked": True}
|
|
73
85
|
```
|
|
74
86
|
|
|
75
87
|
## Authentication
|
|
@@ -94,23 +106,15 @@ stack = Stack()
|
|
|
94
106
|
|
|
95
107
|
See [/docs/security/stack-auth](https://getstack.run/docs/security/stack-auth) for the full auth model and [/docs/security/agent-keys](https://getstack.run/docs/security/agent-keys) for the per-agent keypair story.
|
|
96
108
|
|
|
97
|
-
##
|
|
109
|
+
## Renew a Mission's Passport
|
|
98
110
|
|
|
99
111
|
```python
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
intent="Monitor Slack channels",
|
|
103
|
-
services=["slack"],
|
|
104
|
-
rotation_interval="4h",
|
|
105
|
-
checkpoint_interval="5m",
|
|
106
|
-
):
|
|
107
|
-
while not mission.should_rotate:
|
|
108
|
-
event = wait_for_event()
|
|
109
|
-
mission.log("slack", "read_message")
|
|
110
|
-
process(event)
|
|
111
|
-
# Passport rotates automatically at the boundary
|
|
112
|
+
renewed = stack.missions.renew(mission_id)
|
|
113
|
+
passport_token = renewed["passport"]["token"]
|
|
112
114
|
```
|
|
113
115
|
|
|
116
|
+
Renewal keeps the same Mission, permission and recorded usage. Its deadline and limits still apply. Start a new Mission for the next job.
|
|
117
|
+
|
|
114
118
|
## Services
|
|
115
119
|
|
|
116
120
|
```python
|
|
@@ -17,32 +17,44 @@ npx -y @getstackrun/cli auth login
|
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
```python
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
from getstack.errors import StackError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_fleet_mission_example(stack, request, provider_url="https://slack.com/api/chat.postMessage"):
|
|
26
|
+
started = stack.missions.start(request)
|
|
27
|
+
repeated = stack.missions.start(request)
|
|
28
|
+
assert repeated["mission_id"] == started["mission_id"]
|
|
29
|
+
operation_id = hashlib.sha256(json.dumps([started["mission_id"], "support-update"]).encode()).hexdigest()
|
|
30
|
+
response = stack.proxy.request("slack", provider_url, "POST",
|
|
31
|
+
body={"channel": "C123", "text": "Support update"},
|
|
32
|
+
passport_token=started["passport"]["token"], authority_request_id=operation_id)
|
|
33
|
+
assert response.status == 200
|
|
34
|
+
try:
|
|
35
|
+
stack.proxy.request("slack", provider_url, "POST", body={"channel": "OTHER", "text": "Support update"},
|
|
36
|
+
passport_token=started["passport"]["token"], authority_request_id=operation_id + "_denied")
|
|
37
|
+
except StackError as error:
|
|
38
|
+
assert error.status_code == 403
|
|
39
|
+
else:
|
|
40
|
+
raise AssertionError("Expected channel restriction")
|
|
41
|
+
before = stack.missions.get(started["mission_id"])
|
|
42
|
+
renewed = stack.missions.renew(started["mission_id"])
|
|
43
|
+
after = stack.missions.get(renewed["mission_id"])
|
|
44
|
+
assert renewed["mission_id"] == started["mission_id"]
|
|
45
|
+
assert after["fleet"]["limits"] == before["fleet"]["limits"]
|
|
46
|
+
assert after["fleet"]["usage"] == before["fleet"]["usage"]
|
|
47
|
+
completed = stack.missions.complete(started["mission_id"], output={"done": True})
|
|
48
|
+
assert completed["terminal_state"] == "completed_successful"
|
|
49
|
+
stopped = stack.missions.start({**request, "idempotency_key": request["idempotency_key"] + "-stop"})
|
|
50
|
+
stack.missions.revoke(stopped["mission_id"], reason="operator_initiated_clean")
|
|
51
|
+
try:
|
|
52
|
+
stack.missions.renew(stopped["mission_id"])
|
|
53
|
+
except StackError:
|
|
54
|
+
pass
|
|
55
|
+
else:
|
|
56
|
+
raise AssertionError("Expected revoked Mission")
|
|
57
|
+
return {"mission_id": started["mission_id"], "operation_id": operation_id, "completed": True, "revoked": True}
|
|
46
58
|
```
|
|
47
59
|
|
|
48
60
|
## Authentication
|
|
@@ -67,23 +79,15 @@ stack = Stack()
|
|
|
67
79
|
|
|
68
80
|
See [/docs/security/stack-auth](https://getstack.run/docs/security/stack-auth) for the full auth model and [/docs/security/agent-keys](https://getstack.run/docs/security/agent-keys) for the per-agent keypair story.
|
|
69
81
|
|
|
70
|
-
##
|
|
82
|
+
## Renew a Mission's Passport
|
|
71
83
|
|
|
72
84
|
```python
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
intent="Monitor Slack channels",
|
|
76
|
-
services=["slack"],
|
|
77
|
-
rotation_interval="4h",
|
|
78
|
-
checkpoint_interval="5m",
|
|
79
|
-
):
|
|
80
|
-
while not mission.should_rotate:
|
|
81
|
-
event = wait_for_event()
|
|
82
|
-
mission.log("slack", "read_message")
|
|
83
|
-
process(event)
|
|
84
|
-
# Passport rotates automatically at the boundary
|
|
85
|
+
renewed = stack.missions.renew(mission_id)
|
|
86
|
+
passport_token = renewed["passport"]["token"]
|
|
85
87
|
```
|
|
86
88
|
|
|
89
|
+
Renewal keeps the same Mission, permission and recorded usage. Its deadline and limits still apply. Start a new Mission for the next job.
|
|
90
|
+
|
|
87
91
|
## Services
|
|
88
92
|
|
|
89
93
|
```python
|
|
@@ -1,39 +1,51 @@
|
|
|
1
|
-
"""STACK Python SDK
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
1
|
+
"""STACK Python SDK.
|
|
2
|
+
|
|
3
|
+
Fleet Mission example::
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from getstack.errors import StackError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_fleet_mission_example(stack, request, provider_url="https://slack.com/api/chat.postMessage"):
|
|
11
|
+
started = stack.missions.start(request)
|
|
12
|
+
repeated = stack.missions.start(request)
|
|
13
|
+
assert repeated["mission_id"] == started["mission_id"]
|
|
14
|
+
operation_id = hashlib.sha256(json.dumps([started["mission_id"], "support-update"]).encode()).hexdigest()
|
|
15
|
+
response = stack.proxy.request("slack", provider_url, "POST",
|
|
16
|
+
body={"channel": "C123", "text": "Support update"},
|
|
17
|
+
passport_token=started["passport"]["token"], authority_request_id=operation_id)
|
|
18
|
+
assert response.status == 200
|
|
19
|
+
try:
|
|
20
|
+
stack.proxy.request("slack", provider_url, "POST", body={"channel": "OTHER", "text": "Support update"},
|
|
21
|
+
passport_token=started["passport"]["token"], authority_request_id=operation_id + "_denied")
|
|
22
|
+
except StackError as error:
|
|
23
|
+
assert error.status_code == 403
|
|
24
|
+
else:
|
|
25
|
+
raise AssertionError("Expected channel restriction")
|
|
26
|
+
before = stack.missions.get(started["mission_id"])
|
|
27
|
+
renewed = stack.missions.renew(started["mission_id"])
|
|
28
|
+
after = stack.missions.get(renewed["mission_id"])
|
|
29
|
+
assert renewed["mission_id"] == started["mission_id"]
|
|
30
|
+
assert after["fleet"]["limits"] == before["fleet"]["limits"]
|
|
31
|
+
assert after["fleet"]["usage"] == before["fleet"]["usage"]
|
|
32
|
+
completed = stack.missions.complete(started["mission_id"], output={"done": True})
|
|
33
|
+
assert completed["terminal_state"] == "completed_successful"
|
|
34
|
+
stopped = stack.missions.start({**request, "idempotency_key": request["idempotency_key"] + "-stop"})
|
|
35
|
+
stack.missions.revoke(stopped["mission_id"], reason="operator_initiated_clean")
|
|
36
|
+
try:
|
|
37
|
+
stack.missions.renew(stopped["mission_id"])
|
|
38
|
+
except StackError:
|
|
39
|
+
pass
|
|
40
|
+
else:
|
|
41
|
+
raise AssertionError("Expected revoked Mission")
|
|
42
|
+
return {"mission_id": started["mission_id"], "operation_id": operation_id, "completed": True, "revoked": True}
|
|
32
43
|
"""
|
|
33
44
|
|
|
34
45
|
from __future__ import annotations
|
|
35
46
|
|
|
36
47
|
import os
|
|
48
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
37
49
|
from typing import Any
|
|
38
50
|
|
|
39
51
|
from .auth import ApiKeyAuth, SessionAuth, OAuthAuth, CredentialsFileAuth
|
|
@@ -62,6 +74,8 @@ from .verify_offline import verify_passport_offline
|
|
|
62
74
|
from .sign_claim import sign_claim_envelope
|
|
63
75
|
from .intents import IntentsService
|
|
64
76
|
from .missions import MissionsService
|
|
77
|
+
from .fleets import FleetsService
|
|
78
|
+
from ._async.fleets import AsyncFleetsService
|
|
65
79
|
from .evidence_packs import EvidencePacksService
|
|
66
80
|
from .llm import LlmService
|
|
67
81
|
from .team import TeamService
|
|
@@ -107,7 +121,10 @@ from .errors import (
|
|
|
107
121
|
PassportBlockedError,
|
|
108
122
|
)
|
|
109
123
|
|
|
110
|
-
|
|
124
|
+
try:
|
|
125
|
+
__version__ = version("getstack")
|
|
126
|
+
except PackageNotFoundError:
|
|
127
|
+
__version__ = "0+unknown"
|
|
111
128
|
|
|
112
129
|
|
|
113
130
|
def _build_static_auth(
|
|
@@ -191,6 +208,8 @@ __all__ = [
|
|
|
191
208
|
"DetectorConfigService",
|
|
192
209
|
"PiiConfigService",
|
|
193
210
|
"AuthorityService",
|
|
211
|
+
"FleetsService",
|
|
212
|
+
"AsyncFleetsService",
|
|
194
213
|
"AsyncAuthorityService",
|
|
195
214
|
# Offline / customer-managed crypto (parity with the JS SDK)
|
|
196
215
|
"verify_passport_offline",
|
|
@@ -234,11 +253,10 @@ class Stack:
|
|
|
234
253
|
if _auth:
|
|
235
254
|
auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth = _auth
|
|
236
255
|
elif agent_id:
|
|
237
|
-
static_auth = _build_static_auth(api_key=api_key, base_url=base_url)
|
|
238
256
|
auth = AgentKeypairAuth(
|
|
239
257
|
agent_id=agent_id,
|
|
240
258
|
api_base_url=base_url,
|
|
241
|
-
bearer_provider=lambda:
|
|
259
|
+
bearer_provider=lambda: _build_static_auth(api_key=api_key, base_url=base_url).get_headers()["Authorization"].split(" ", 1)[1],
|
|
242
260
|
)
|
|
243
261
|
elif api_key or os.environ.get("STACK_API_KEY"):
|
|
244
262
|
auth = ApiKeyAuth(api_key or os.environ["STACK_API_KEY"])
|
|
@@ -262,6 +280,7 @@ class Stack:
|
|
|
262
280
|
self.identity = IdentityService(self._client)
|
|
263
281
|
self.intents = IntentsService(self._client)
|
|
264
282
|
self.missions = MissionsService(self._client)
|
|
283
|
+
self.fleets = FleetsService(self._client)
|
|
265
284
|
self.evidence_packs = EvidencePacksService(self._client)
|
|
266
285
|
self.llm = LlmService(self._client)
|
|
267
286
|
self.inbound_webhooks = InboundWebhookService(self._client)
|
|
@@ -381,11 +400,10 @@ class AsyncStack:
|
|
|
381
400
|
if _auth:
|
|
382
401
|
auth: ApiKeyAuth | SessionAuth | OAuthAuth | CredentialsFileAuth | AgentKeypairAuth = _auth
|
|
383
402
|
elif agent_id:
|
|
384
|
-
static_auth = _build_static_auth(api_key=api_key, base_url=base_url)
|
|
385
403
|
auth = AgentKeypairAuth(
|
|
386
404
|
agent_id=agent_id,
|
|
387
405
|
api_base_url=base_url,
|
|
388
|
-
bearer_provider=lambda:
|
|
406
|
+
bearer_provider=lambda: _build_static_auth(api_key=api_key, base_url=base_url).get_headers()["Authorization"].split(" ", 1)[1],
|
|
389
407
|
)
|
|
390
408
|
elif api_key or os.environ.get("STACK_API_KEY"):
|
|
391
409
|
auth = ApiKeyAuth(api_key or os.environ["STACK_API_KEY"])
|
|
@@ -441,6 +459,7 @@ class AsyncStack:
|
|
|
441
459
|
self.identity = AsyncIdentityService(self._client)
|
|
442
460
|
self.intents = AsyncIntentsService(self._client)
|
|
443
461
|
self.missions = AsyncMissionsService(self._client)
|
|
462
|
+
self.fleets = AsyncFleetsService(self._client)
|
|
444
463
|
self.llm = AsyncLlmService(self._client)
|
|
445
464
|
self.inbound_webhooks = AsyncInboundWebhookService(self._client)
|
|
446
465
|
self.partner_tenants = AsyncPartnerTenantsService(self._client)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
from .._fleet_key import prepare_fleet_key
|
|
6
|
+
from ..client import AsyncHttpClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AsyncFleetsService:
|
|
10
|
+
def __init__(self, client: AsyncHttpClient):
|
|
11
|
+
self._client = client
|
|
12
|
+
|
|
13
|
+
async def session(self, fleet_policy_id: str | None = None) -> dict[str, Any]:
|
|
14
|
+
return await self._client.request("POST", "/v1/fleets/session", json={"fleet_policy_id": fleet_policy_id} if fleet_policy_id else {})
|
|
15
|
+
|
|
16
|
+
async def workload_enrollment_challenge(self, policy_id: str) -> dict[str, Any]:
|
|
17
|
+
return await self._client.request("POST", f"/v1/fleets/{quote(policy_id, safe='')}/workload/enrollment-challenges", json={})
|
|
18
|
+
|
|
19
|
+
async def enroll_workload(self, policy_id: str, request: dict[str, Any]) -> dict[str, Any]:
|
|
20
|
+
return await self._client.request("POST", f"/v1/fleets/{quote(policy_id, safe='')}/workload/enroll", json=request)
|
|
21
|
+
|
|
22
|
+
async def enroll_workload_local(self, policy_id: str, *, name: str, idempotency_key: str) -> dict[str, Any]:
|
|
23
|
+
public_key, sign, persist = prepare_fleet_key(policy_id, idempotency_key)
|
|
24
|
+
challenge = await self.workload_enrollment_challenge(policy_id)
|
|
25
|
+
result = await self.enroll_workload(policy_id, {"name": name, "idempotency_key": idempotency_key,
|
|
26
|
+
"public_key": public_key, "challenge_id": challenge["challenge_id"], "proof": sign(challenge["challenge"])})
|
|
27
|
+
persist(result["agent_id"])
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
async def enroll_local(self, policy_id: str, *, name: str, idempotency_key: str) -> dict[str, Any]:
|
|
31
|
+
public_key, sign, persist = prepare_fleet_key(policy_id, idempotency_key)
|
|
32
|
+
challenge = await self.enrollment_challenge(policy_id)
|
|
33
|
+
result = await self.enroll(policy_id, {"name": name, "idempotency_key": idempotency_key,
|
|
34
|
+
"public_key": public_key, "challenge_id": challenge["challenge_id"], "proof": sign(challenge["challenge"])})
|
|
35
|
+
persist(result["agent_id"])
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
39
|
+
return await self._client.request("GET", "/v1/fleets")
|
|
40
|
+
|
|
41
|
+
async def list_page(self, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
|
42
|
+
return await self._client.request("GET", "/v1/fleets", params={"page": page, "limit": limit})
|
|
43
|
+
|
|
44
|
+
async def get(self, policy_id: str) -> dict[str, Any]:
|
|
45
|
+
return await self._client.request("GET", f"/v1/fleets/{quote(policy_id, safe='')}")
|
|
46
|
+
|
|
47
|
+
async def membership(self, agent_id: str) -> dict[str, Any] | None:
|
|
48
|
+
return await self._client.request("GET", f"/v1/fleets/membership/{quote(agent_id, safe='')}")
|
|
49
|
+
|
|
50
|
+
async def members_page(self, policy_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
|
51
|
+
return await self._client.request("GET", f"/v1/fleets/{quote(policy_id, safe='')}/members", params={"page": page, "limit": limit})
|
|
52
|
+
|
|
53
|
+
async def activate(self, definition: dict[str, Any], *, idempotency_key: str,
|
|
54
|
+
governance_approval_id: str | None = None) -> dict[str, Any]:
|
|
55
|
+
headers = {"Idempotency-Key": idempotency_key}
|
|
56
|
+
if governance_approval_id is not None:
|
|
57
|
+
headers["X-Governance-Approval"] = governance_approval_id
|
|
58
|
+
return await self._client.request("POST", "/v1/fleets", json=definition, extra_headers=headers)
|
|
59
|
+
|
|
60
|
+
async def revise(self, policy_id: str, definition: dict[str, Any], *,
|
|
61
|
+
expected_revision: int, idempotency_key: str,
|
|
62
|
+
governance_approval_id: str | None = None) -> dict[str, Any]:
|
|
63
|
+
headers = {"If-Match": str(expected_revision), "Idempotency-Key": idempotency_key}
|
|
64
|
+
if governance_approval_id is not None:
|
|
65
|
+
headers["X-Governance-Approval"] = governance_approval_id
|
|
66
|
+
return await self._client.request("PUT", f"/v1/fleets/{quote(policy_id, safe='')}",
|
|
67
|
+
json=definition, extra_headers=headers)
|
|
68
|
+
|
|
69
|
+
async def members(self, policy_id: str) -> list[dict[str, Any]]:
|
|
70
|
+
return await self._client.request("GET", f"/v1/fleets/{quote(policy_id, safe='')}/members")
|
|
71
|
+
|
|
72
|
+
async def revoke_member(self, policy_id: str, agent_id: str) -> dict[str, Any]:
|
|
73
|
+
return await self._client.request("POST", f"/v1/fleets/{quote(policy_id, safe='')}/members/{quote(agent_id, safe='')}/revoke", json={})
|
|
74
|
+
|
|
75
|
+
async def revoke(self, policy_id: str) -> dict[str, Any]:
|
|
76
|
+
return await self._client.request("POST", f"/v1/fleets/{quote(policy_id, safe='')}/revoke", json={})
|
|
77
|
+
|
|
78
|
+
async def enrollment_challenge(self, policy_id: str) -> dict[str, Any]:
|
|
79
|
+
return await self._client.request("POST", f"/v1/fleets/{quote(policy_id, safe='')}/enrollment-challenges", json={})
|
|
80
|
+
|
|
81
|
+
async def enroll(self, policy_id: str, request: dict[str, Any]) -> dict[str, Any]:
|
|
82
|
+
return await self._client.request("POST", f"/v1/fleets/{quote(policy_id, safe='')}/enroll", json=request)
|
|
@@ -12,6 +12,12 @@ class AsyncMissionsService:
|
|
|
12
12
|
def __init__(self, client: AsyncHttpClient):
|
|
13
13
|
self._client = client
|
|
14
14
|
|
|
15
|
+
async def start(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
16
|
+
return await self._client.request("POST", "/v1/missions/start", json=request)
|
|
17
|
+
|
|
18
|
+
async def renew(self, mission_id: str) -> dict[str, Any]:
|
|
19
|
+
return await self._client.request("POST", f"/v1/missions/{quote(mission_id, safe='')}/renew", json={})
|
|
20
|
+
|
|
15
21
|
async def open(
|
|
16
22
|
self,
|
|
17
23
|
intent_summary: str,
|
|
@@ -29,6 +35,12 @@ class AsyncMissionsService:
|
|
|
29
35
|
cap_behavior: dict[str, str] | None = None,
|
|
30
36
|
expires_in_seconds: int | None = None,
|
|
31
37
|
parent_passport_services: list[str] | None = None,
|
|
38
|
+
fleet_policy_id: str | None = None,
|
|
39
|
+
idempotency_key: str | None = None,
|
|
40
|
+
requested_access: list[dict[str, Any]] | None = None,
|
|
41
|
+
requested_operations: list[dict[str, Any]] | None = None,
|
|
42
|
+
limits: dict[str, Any] | None = None,
|
|
43
|
+
authority_binding_ids: list[str] | None = None,
|
|
32
44
|
) -> dict[str, Any]:
|
|
33
45
|
body: dict[str, Any] = {"intent_summary": intent_summary}
|
|
34
46
|
if intent_services is not None:
|
|
@@ -57,6 +69,11 @@ class AsyncMissionsService:
|
|
|
57
69
|
body["expires_in_seconds"] = expires_in_seconds
|
|
58
70
|
if parent_passport_services is not None:
|
|
59
71
|
body["parent_passport_services"] = parent_passport_services
|
|
72
|
+
for key, value in {"fleet_policy_id": fleet_policy_id, "idempotency_key": idempotency_key,
|
|
73
|
+
"requested_access": requested_access, "requested_operations": requested_operations,
|
|
74
|
+
"limits": limits, "authority_binding_ids": authority_binding_ids}.items():
|
|
75
|
+
if value is not None:
|
|
76
|
+
body[key] = value
|
|
60
77
|
return await self._client.request("POST", "/v1/missions", json=body)
|
|
61
78
|
|
|
62
79
|
async def complete(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Async mirror of PartnerTenantsService (
|
|
1
|
+
"""Async mirror of PartnerTenantsService (white-label tenants)."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -8,8 +8,6 @@ from urllib.parse import quote
|
|
|
8
8
|
from ..client import AsyncHttpClient
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
Tier = Literal["free", "developer", "pro", "business", "enterprise"]
|
|
12
|
-
|
|
13
11
|
|
|
14
12
|
class AsyncPartnerTenantsService:
|
|
15
13
|
def __init__(self, client: AsyncHttpClient):
|
|
@@ -18,23 +16,36 @@ class AsyncPartnerTenantsService:
|
|
|
18
16
|
async def create(
|
|
19
17
|
self,
|
|
20
18
|
external_customer_id: Optional[str] = None,
|
|
21
|
-
tier: Optional[Tier] = None,
|
|
22
19
|
) -> dict[str, Any]:
|
|
20
|
+
"""Create a white-label tenant.
|
|
21
|
+
|
|
22
|
+
Tenants have no plan of their own; they draw from your allowance
|
|
23
|
+
and wallet. Returns operator_id, api_key (shown once — store it),
|
|
24
|
+
external_customer_id, tier ('partner'), billing ('partner') and
|
|
25
|
+
created_at. Raises with code TENANT_LIMIT_REACHED (409) when your
|
|
26
|
+
deal caps the tenant count.
|
|
27
|
+
"""
|
|
23
28
|
body: dict[str, Any] = {}
|
|
24
29
|
if external_customer_id is not None:
|
|
25
30
|
body["external_customer_id"] = external_customer_id
|
|
26
|
-
if tier is not None:
|
|
27
|
-
body["tier"] = tier
|
|
28
31
|
return await self._client.post("/v1/partner/tenants", json=body)
|
|
29
32
|
|
|
30
33
|
async def list(self) -> list[dict[str, Any]]:
|
|
34
|
+
"""List your tenants. Each carries ``usage`` ({period, actions}):
|
|
35
|
+
its month-to-date share of your action pool, when available."""
|
|
31
36
|
res = await self._client.get("/v1/partner/tenants")
|
|
32
37
|
return list(res.get("tenants", []))
|
|
33
38
|
|
|
34
39
|
async def get(self, tenant_id: str) -> dict[str, Any]:
|
|
40
|
+
"""Fetch one tenant (same shape as a ``list`` entry)."""
|
|
35
41
|
return await self._client.get(f"/v1/partner/tenants/{tenant_id}")
|
|
36
42
|
|
|
37
43
|
async def revoke(self, tenant_id: str) -> dict[str, Any]:
|
|
44
|
+
"""Revoke a tenant. Cascades to its inbound webhooks and passports.
|
|
45
|
+
|
|
46
|
+
Returns {transitioned, webhooks_revoked, passports_revoked}.
|
|
47
|
+
Idempotent: a retry finishes a cascade that failed part-way.
|
|
48
|
+
"""
|
|
38
49
|
return await self._client.delete(f"/v1/partner/tenants/{tenant_id}")
|
|
39
50
|
|
|
40
51
|
async def create_inbound_webhook(
|
|
@@ -14,7 +14,7 @@ from contextlib import asynccontextmanager
|
|
|
14
14
|
from typing import Any, AsyncGenerator, Awaitable, Callable
|
|
15
15
|
|
|
16
16
|
from ..client import AsyncHttpClient
|
|
17
|
-
from ..passports import _parse_duration, _to_passport
|
|
17
|
+
from ..passports import _parse_duration, _to_passport, _delegation_scopes
|
|
18
18
|
from ..proxy import ProxyResponse
|
|
19
19
|
from .proxy import AsyncProxyService
|
|
20
20
|
from ..types import Passport, CheckpointResult, CheckoutResult, PassportReport, ToolCall
|
|
@@ -217,6 +217,7 @@ class AsyncPassportService:
|
|
|
217
217
|
ttl_seconds: int | None = None,
|
|
218
218
|
scopes: list[dict[str, Any]] | None = None,
|
|
219
219
|
authority_binding_ids: list[str] | None = None,
|
|
220
|
+
mission_ids: list[str] | None = None,
|
|
220
221
|
) -> Passport:
|
|
221
222
|
"""Issue a signed passport for an agent (async mirror of ``Stack``).
|
|
222
223
|
|
|
@@ -246,6 +247,8 @@ class AsyncPassportService:
|
|
|
246
247
|
body["scopes"] = scopes
|
|
247
248
|
if authority_binding_ids is not None:
|
|
248
249
|
body["authority_binding_ids"] = authority_binding_ids
|
|
250
|
+
if mission_ids is not None:
|
|
251
|
+
body["mission_ids"] = mission_ids
|
|
249
252
|
if ttl_seconds:
|
|
250
253
|
body["ttl_seconds"] = ttl_seconds
|
|
251
254
|
if intent or services:
|
|
@@ -339,12 +342,11 @@ class AsyncPassportService:
|
|
|
339
342
|
self,
|
|
340
343
|
parent_token: str,
|
|
341
344
|
child_agent_id: str,
|
|
342
|
-
scopes: list[str] | None = None,
|
|
345
|
+
scopes: list[dict[str, Any]] | list[str] | None = None,
|
|
343
346
|
ttl_seconds: int | None = None,
|
|
344
347
|
) -> Passport:
|
|
345
|
-
body: dict[str, Any] = {"
|
|
346
|
-
|
|
347
|
-
body["scopes"] = scopes
|
|
348
|
+
body: dict[str, Any] = {"parent_passport_token": parent_token, "child_agent_id": child_agent_id,
|
|
349
|
+
"scopes": _delegation_scopes(parent_token, scopes)}
|
|
348
350
|
if ttl_seconds is not None:
|
|
349
351
|
body["ttl_seconds"] = ttl_seconds
|
|
350
352
|
return _to_passport(await self._client.post("/v1/passports/delegate", json=body))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .agent_auth import _generate_keypair, _key_path, _load_stored, _persist_stored, _privkey_from_jwk, _b64url_no_pad
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def prepare_fleet_key(policy_id: str, idempotency_key: str):
|
|
13
|
+
key_id = "fleet_" + hashlib.sha256(json.dumps([policy_id, idempotency_key], separators=(",", ":")).encode()).hexdigest()
|
|
14
|
+
stored = _load_stored(key_id)
|
|
15
|
+
if stored is None:
|
|
16
|
+
_, public_key, private_key = _generate_keypair()
|
|
17
|
+
candidate = {"agent_id": key_id, "publicKey": public_key, "privateKey": private_key, "enrolled_at": time.time()}
|
|
18
|
+
path = _key_path(key_id)
|
|
19
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
try:
|
|
21
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
22
|
+
with os.fdopen(fd, "w", encoding="utf8") as stream:
|
|
23
|
+
json.dump(candidate, stream)
|
|
24
|
+
except FileExistsError:
|
|
25
|
+
pass
|
|
26
|
+
stored = _load_stored(key_id)
|
|
27
|
+
for _ in range(20):
|
|
28
|
+
if stored is not None:
|
|
29
|
+
break
|
|
30
|
+
time.sleep(0.025)
|
|
31
|
+
stored = _load_stored(key_id)
|
|
32
|
+
if stored is None:
|
|
33
|
+
raise ValueError("Invalid input")
|
|
34
|
+
key = _privkey_from_jwk(stored["privateKey"])
|
|
35
|
+
|
|
36
|
+
def sign(challenge: str) -> str:
|
|
37
|
+
return _b64url_no_pad(key.sign(challenge.encode()))
|
|
38
|
+
|
|
39
|
+
def persist(agent_id: str) -> None:
|
|
40
|
+
if not re.fullmatch(r"agt_[\w-]+", agent_id):
|
|
41
|
+
raise ValueError("Invalid input")
|
|
42
|
+
_persist_stored(agent_id, {**stored, "agent_id": agent_id})
|
|
43
|
+
|
|
44
|
+
return stored["publicKey"], sign, persist
|