getstack 1.3.0__tar.gz → 1.4.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-1.4.0}/.gitignore +3 -0
- {getstack-1.3.0 → getstack-1.4.0}/PKG-INFO +44 -40
- {getstack-1.3.0 → getstack-1.4.0}/README.md +43 -39
- {getstack-1.3.0 → getstack-1.4.0}/pyproject.toml +1 -1
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/__init__.py +55 -36
- getstack-1.4.0/src/getstack/_async/fleets.py +82 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/missions.py +17 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/passports.py +7 -5
- getstack-1.4.0/src/getstack/_fleet_key.py +44 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/agent_auth.py +9 -1
- getstack-1.4.0/src/getstack/fleets.py +82 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/missions.py +17 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/passports.py +27 -4
- getstack-1.4.0/tests/test_fleets.py +94 -0
- {getstack-1.3.0 → getstack-1.4.0}/tests/test_parity.py +7 -1
- {getstack-1.3.0 → getstack-1.4.0}/CLAUDE.md +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/LICENSE +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/scripts/verify_sign_claim_crosslang.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/__init__.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/agents.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/audit.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/authority.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/credentials.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/delivery_methods.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/detector_config.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/dropoffs.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/evidence_packs.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/identity.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/inbound_webhooks.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/intents.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/llm.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/notifications.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/partner_connect.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/partner_tenants.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/passport_session.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/pii_config.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/proxy.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/reviews.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/scan.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/security_events.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/services.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/skills.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/team.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/usage.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_authority_request.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/agents.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/audit.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/auth.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/authority.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/browser_bootstrap.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/client.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/credentials.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/delivery_methods.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/detector_config.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/dropoffs.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/errors.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/evidence_packs.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/identity.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/inbound_webhooks.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/intents.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/llm.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/notifications.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/partner_connect.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/partner_tenants.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/passport_header_verify.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/passport_session.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/pii_config.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/proxy.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/py.typed +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/reviews.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/scan.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/security_events.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/services.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/sign_claim.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/skills.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/team.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/types.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/usage.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/src/getstack/verify_offline.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/tests/test_authority.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/tests/test_delivery_methods.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/tests/test_dropoffs.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/tests/test_errors.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/tests/test_evidence_packs.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/tests/test_identity_requirements.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/tests/test_inbound_webhooks.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/tests/test_missions.py +0 -0
- {getstack-1.3.0 → getstack-1.4.0}/tests/test_partner_signing.py +0 -0
- {getstack-1.3.0 → getstack-1.4.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.
|
|
3
|
+
Version: 1.4.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(
|
|
@@ -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
|
|
@@ -181,9 +181,17 @@ def _load_stored(agent_id: str) -> dict | None:
|
|
|
181
181
|
|
|
182
182
|
|
|
183
183
|
def _persist_stored(agent_id: str, payload: dict) -> None:
|
|
184
|
+
import tempfile
|
|
184
185
|
path = _key_path(agent_id)
|
|
185
186
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
-
|
|
187
|
+
fd, pending = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
188
|
+
try:
|
|
189
|
+
with os.fdopen(fd, "w", encoding="utf8") as stream:
|
|
190
|
+
json.dump(payload, stream, indent=2)
|
|
191
|
+
os.replace(pending, path)
|
|
192
|
+
finally:
|
|
193
|
+
if os.path.exists(pending):
|
|
194
|
+
os.unlink(pending)
|
|
187
195
|
try:
|
|
188
196
|
os.chmod(path, 0o600)
|
|
189
197
|
except OSError:
|
|
@@ -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 HttpClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FleetsService:
|
|
10
|
+
def __init__(self, client: HttpClient):
|
|
11
|
+
self._client = client
|
|
12
|
+
|
|
13
|
+
def session(self, fleet_policy_id: str | None = None) -> dict[str, Any]:
|
|
14
|
+
return self._client.request("POST", "/v1/fleets/session", json={"fleet_policy_id": fleet_policy_id} if fleet_policy_id else {})
|
|
15
|
+
|
|
16
|
+
def workload_enrollment_challenge(self, policy_id: str) -> dict[str, Any]:
|
|
17
|
+
return self._client.request("POST", f"/v1/fleets/{quote(policy_id, safe='')}/workload/enrollment-challenges", json={})
|
|
18
|
+
|
|
19
|
+
def enroll_workload(self, policy_id: str, request: dict[str, Any]) -> dict[str, Any]:
|
|
20
|
+
return self._client.request("POST", f"/v1/fleets/{quote(policy_id, safe='')}/workload/enroll", json=request)
|
|
21
|
+
|
|
22
|
+
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 = self.workload_enrollment_challenge(policy_id)
|
|
25
|
+
result = 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
|
+
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 = self.enrollment_challenge(policy_id)
|
|
33
|
+
result = 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
|
+
def list(self) -> list[dict[str, Any]]:
|
|
39
|
+
return self._client.request("GET", "/v1/fleets")
|
|
40
|
+
|
|
41
|
+
def list_page(self, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
|
42
|
+
return self._client.request("GET", "/v1/fleets", params={"page": page, "limit": limit})
|
|
43
|
+
|
|
44
|
+
def get(self, policy_id: str) -> dict[str, Any]:
|
|
45
|
+
return self._client.request("GET", f"/v1/fleets/{quote(policy_id, safe='')}")
|
|
46
|
+
|
|
47
|
+
def membership(self, agent_id: str) -> dict[str, Any] | None:
|
|
48
|
+
return self._client.request("GET", f"/v1/fleets/membership/{quote(agent_id, safe='')}")
|
|
49
|
+
|
|
50
|
+
def members_page(self, policy_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
|
51
|
+
return self._client.request("GET", f"/v1/fleets/{quote(policy_id, safe='')}/members", params={"page": page, "limit": limit})
|
|
52
|
+
|
|
53
|
+
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 self._client.request("POST", "/v1/fleets", json=definition, extra_headers=headers)
|
|
59
|
+
|
|
60
|
+
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 self._client.request("PUT", f"/v1/fleets/{quote(policy_id, safe='')}",
|
|
67
|
+
json=definition, extra_headers=headers)
|
|
68
|
+
|
|
69
|
+
def members(self, policy_id: str) -> list[dict[str, Any]]:
|
|
70
|
+
return self._client.request("GET", f"/v1/fleets/{quote(policy_id, safe='')}/members")
|
|
71
|
+
|
|
72
|
+
def revoke_member(self, policy_id: str, agent_id: str) -> dict[str, Any]:
|
|
73
|
+
return self._client.request("POST", f"/v1/fleets/{quote(policy_id, safe='')}/members/{quote(agent_id, safe='')}/revoke", json={})
|
|
74
|
+
|
|
75
|
+
def revoke(self, policy_id: str) -> dict[str, Any]:
|
|
76
|
+
return self._client.request("POST", f"/v1/fleets/{quote(policy_id, safe='')}/revoke", json={})
|
|
77
|
+
|
|
78
|
+
def enrollment_challenge(self, policy_id: str) -> dict[str, Any]:
|
|
79
|
+
return self._client.request("POST", f"/v1/fleets/{quote(policy_id, safe='')}/enrollment-challenges", json={})
|
|
80
|
+
|
|
81
|
+
def enroll(self, policy_id: str, request: dict[str, Any]) -> dict[str, Any]:
|
|
82
|
+
return self._client.request("POST", f"/v1/fleets/{quote(policy_id, safe='')}/enroll", json=request)
|
|
@@ -25,6 +25,12 @@ class MissionsService:
|
|
|
25
25
|
def __init__(self, client: HttpClient):
|
|
26
26
|
self._client = client
|
|
27
27
|
|
|
28
|
+
def start(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
29
|
+
return self._client.request("POST", "/v1/missions/start", json=request)
|
|
30
|
+
|
|
31
|
+
def renew(self, mission_id: str) -> dict[str, Any]:
|
|
32
|
+
return self._client.request("POST", f"/v1/missions/{quote(mission_id, safe='')}/renew", json={})
|
|
33
|
+
|
|
28
34
|
def open(
|
|
29
35
|
self,
|
|
30
36
|
intent_summary: str,
|
|
@@ -42,6 +48,12 @@ class MissionsService:
|
|
|
42
48
|
cap_behavior: dict[str, str] | None = None,
|
|
43
49
|
expires_in_seconds: int | None = None,
|
|
44
50
|
parent_passport_services: list[str] | None = None,
|
|
51
|
+
fleet_policy_id: str | None = None,
|
|
52
|
+
idempotency_key: str | None = None,
|
|
53
|
+
requested_access: list[dict[str, Any]] | None = None,
|
|
54
|
+
requested_operations: list[dict[str, Any]] | None = None,
|
|
55
|
+
limits: dict[str, Any] | None = None,
|
|
56
|
+
authority_binding_ids: list[str] | None = None,
|
|
45
57
|
) -> dict[str, Any]:
|
|
46
58
|
"""Open a new mission. Returns dict with mission_id, claim_id,
|
|
47
59
|
expires_at."""
|
|
@@ -72,6 +84,11 @@ class MissionsService:
|
|
|
72
84
|
body["expires_in_seconds"] = expires_in_seconds
|
|
73
85
|
if parent_passport_services is not None:
|
|
74
86
|
body["parent_passport_services"] = parent_passport_services
|
|
87
|
+
for key, value in {"fleet_policy_id": fleet_policy_id, "idempotency_key": idempotency_key,
|
|
88
|
+
"requested_access": requested_access, "requested_operations": requested_operations,
|
|
89
|
+
"limits": limits, "authority_binding_ids": authority_binding_ids}.items():
|
|
90
|
+
if value is not None:
|
|
91
|
+
body[key] = value
|
|
75
92
|
return self._client.request("POST", "/v1/missions", json=body)
|
|
76
93
|
|
|
77
94
|
def complete(
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import re
|
|
6
6
|
import time
|
|
7
7
|
import threading
|
|
8
|
+
import jwt
|
|
8
9
|
from contextlib import contextmanager
|
|
9
10
|
from typing import Any, Callable, Generator
|
|
10
11
|
|
|
@@ -31,6 +32,26 @@ def _to_passport(data: dict[str, Any]) -> Passport:
|
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def _delegation_scopes(token: str, scopes: list[dict[str, Any]] | list[str] | None):
|
|
36
|
+
if scopes is not None and all(isinstance(scope, dict) for scope in scopes):
|
|
37
|
+
return scopes
|
|
38
|
+
# Request construction only. The API authenticates the parent Passport.
|
|
39
|
+
services = jwt.decode(token, options={"verify_signature": False, "verify_aud": False}).get("stk", {}).get("services")
|
|
40
|
+
if not isinstance(services, list):
|
|
41
|
+
raise ValueError("Invalid input")
|
|
42
|
+
selected = [service for service in services if scopes is None or service.get("service_name") in scopes]
|
|
43
|
+
if scopes is not None and any(not any(service.get("service_name") == name for service in selected) for name in scopes):
|
|
44
|
+
raise ValueError("Invalid input")
|
|
45
|
+
result = []
|
|
46
|
+
for service in selected:
|
|
47
|
+
if not isinstance(service.get("connection_id"), str) or not isinstance(service.get("scopes"), list):
|
|
48
|
+
raise ValueError("Invalid input")
|
|
49
|
+
result.append({"service_connection_id": service["connection_id"], "scopes": service["scopes"],
|
|
50
|
+
**({"constraints": service["constraints"]} if "constraints" in service else {}),
|
|
51
|
+
**({"allow_data_from": service["allow_data_from"]} if "allow_data_from" in service else {})})
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
34
55
|
class Mission:
|
|
35
56
|
"""A bounded accountability window for an agent mission.
|
|
36
57
|
|
|
@@ -246,6 +267,7 @@ class PassportService:
|
|
|
246
267
|
ttl_seconds: int | None = None,
|
|
247
268
|
scopes: list[dict[str, Any]] | None = None,
|
|
248
269
|
authority_binding_ids: list[str] | None = None,
|
|
270
|
+
mission_ids: list[str] | None = None,
|
|
249
271
|
) -> Passport:
|
|
250
272
|
"""Issue a signed passport for an agent.
|
|
251
273
|
|
|
@@ -275,6 +297,8 @@ class PassportService:
|
|
|
275
297
|
body["scopes"] = scopes
|
|
276
298
|
if authority_binding_ids is not None:
|
|
277
299
|
body["authority_binding_ids"] = authority_binding_ids
|
|
300
|
+
if mission_ids is not None:
|
|
301
|
+
body["mission_ids"] = mission_ids
|
|
278
302
|
if ttl_seconds:
|
|
279
303
|
body["ttl_seconds"] = ttl_seconds
|
|
280
304
|
if intent or services:
|
|
@@ -369,14 +393,13 @@ class PassportService:
|
|
|
369
393
|
self,
|
|
370
394
|
parent_token: str,
|
|
371
395
|
child_agent_id: str,
|
|
372
|
-
scopes: list[str] | None = None,
|
|
396
|
+
scopes: list[dict[str, Any]] | list[str] | None = None,
|
|
373
397
|
ttl_seconds: int | None = None,
|
|
374
398
|
) -> Passport:
|
|
375
399
|
"""Delegate a passport to a child agent. Scope narrows only, max 4
|
|
376
400
|
hops (enforced server-side)."""
|
|
377
|
-
body: dict[str, Any] = {"
|
|
378
|
-
|
|
379
|
-
body["scopes"] = scopes
|
|
401
|
+
body: dict[str, Any] = {"parent_passport_token": parent_token, "child_agent_id": child_agent_id,
|
|
402
|
+
"scopes": _delegation_scopes(parent_token, scopes)}
|
|
380
403
|
if ttl_seconds is not None:
|
|
381
404
|
body["ttl_seconds"] = ttl_seconds
|
|
382
405
|
return _to_passport(self._client.post("/v1/passports/delegate", json=body))
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import pytest
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
9
|
+
|
|
10
|
+
from getstack.fleets import FleetsService
|
|
11
|
+
from getstack._async.fleets import AsyncFleetsService
|
|
12
|
+
from getstack.missions import MissionsService
|
|
13
|
+
from getstack._async.missions import AsyncMissionsService
|
|
14
|
+
from getstack.passports import PassportService
|
|
15
|
+
from getstack._async.passports import AsyncPassportService
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.parametrize("client_name", ["Stack", "AsyncStack"])
|
|
19
|
+
def test_enrolled_worker_needs_no_operator_credentials(monkeypatch, client_name):
|
|
20
|
+
import getstack
|
|
21
|
+
from getstack import agent_auth
|
|
22
|
+
import jwt
|
|
23
|
+
def no_operator_credentials(**kwargs):
|
|
24
|
+
raise AssertionError("Enrolled worker requested operator credentials")
|
|
25
|
+
monkeypatch.setattr(getstack, "_build_static_auth", no_operator_credentials)
|
|
26
|
+
_, public, private = agent_auth._generate_keypair()
|
|
27
|
+
monkeypatch.setattr(agent_auth, "_load_stored", lambda agent_id: {"agent_id": agent_id,
|
|
28
|
+
"publicKey": public, "privateKey": private, "enrolled_at": 1})
|
|
29
|
+
client = getattr(getstack, client_name)(agent_id="agt_worker")
|
|
30
|
+
headers = client._client.auth.get_headers()
|
|
31
|
+
claims = jwt.decode(headers["Authorization"].split(" ", 1)[1], options={"verify_signature": False})
|
|
32
|
+
assert claims["sub"] == "agt_worker"
|
|
33
|
+
assert claims["aud"] == "stack:agent"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Capture:
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self.calls = []
|
|
39
|
+
|
|
40
|
+
def request(self, method, path, **kwargs):
|
|
41
|
+
self.calls.append((method, path, kwargs))
|
|
42
|
+
return {"token": "fixture", "jti": "jti_fixture", "expires_at": "2030-01-01"}
|
|
43
|
+
|
|
44
|
+
def post(self, path, **kwargs):
|
|
45
|
+
return self.request("POST", path, **kwargs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AsyncCapture(Capture):
|
|
49
|
+
async def request(self, method, path, **kwargs):
|
|
50
|
+
return super().request(method, path, **kwargs)
|
|
51
|
+
|
|
52
|
+
async def post(self, path, **kwargs):
|
|
53
|
+
return await self.request("POST", path, **kwargs)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
REQUEST = {"fleet_policy_id": "flp_fixture", "idempotency_key": "operation-123", "intent_summary": "Support update",
|
|
57
|
+
"requested_access": [{"connection_id": "scon_fixture", "scopes": ["chat:write"], "intents": [
|
|
58
|
+
{"name": "slack.post_to_channel", "params": {"channel": "C123"}}], "allow_data_from": ["github"]}],
|
|
59
|
+
"requested_operations": [{"producer": "skill", "target_id": "skl_fixture", "action": "invoke"}],
|
|
60
|
+
"limits": {"max_actions": 20, "max_concurrent_actions": 2, "max_duration_seconds": 900},
|
|
61
|
+
"authority_binding_ids": ["abn_fixture"], "expected_output_shape": {"type": "object"}}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_sync_async_fleet_contract_matches():
|
|
65
|
+
sync = Capture()
|
|
66
|
+
fleets = FleetsService(sync)
|
|
67
|
+
fleets.revise("flp_one/two", {"name": "Policy"}, expected_revision=2, idempotency_key="revision-123", governance_approval_id="gvr_fixture")
|
|
68
|
+
fleets.session()
|
|
69
|
+
fleets.members("flp_one/two")
|
|
70
|
+
fleets.revoke_member("flp_one/two", "agt_one/two")
|
|
71
|
+
MissionsService(sync).start(REQUEST)
|
|
72
|
+
MissionsService(sync).renew("ms_one/two")
|
|
73
|
+
PassportService(sync).issue("agt_fixture", mission_ids=["ms_fixture"])
|
|
74
|
+
PassportService(sync).delegate("fixture", "agt_child", scopes=[{"service_connection_id": "scon_fixture", "scopes": ["read"]}])
|
|
75
|
+
|
|
76
|
+
async def run():
|
|
77
|
+
client = AsyncCapture()
|
|
78
|
+
fleets = AsyncFleetsService(client)
|
|
79
|
+
await fleets.revise("flp_one/two", {"name": "Policy"}, expected_revision=2, idempotency_key="revision-123", governance_approval_id="gvr_fixture")
|
|
80
|
+
await fleets.session()
|
|
81
|
+
await fleets.members("flp_one/two")
|
|
82
|
+
await fleets.revoke_member("flp_one/two", "agt_one/two")
|
|
83
|
+
await AsyncMissionsService(client).start(REQUEST)
|
|
84
|
+
await AsyncMissionsService(client).renew("ms_one/two")
|
|
85
|
+
await AsyncPassportService(client).issue("agt_fixture", mission_ids=["ms_fixture"])
|
|
86
|
+
await AsyncPassportService(client).delegate("fixture", "agt_child", scopes=[{"service_connection_id": "scon_fixture", "scopes": ["read"]}])
|
|
87
|
+
return client.calls
|
|
88
|
+
|
|
89
|
+
assert asyncio.run(run()) == sync.calls
|
|
90
|
+
assert sync.calls[0] == ("PUT", "/v1/fleets/flp_one%2Ftwo", {"json": {"name": "Policy"}, "extra_headers": {
|
|
91
|
+
"If-Match": "2", "Idempotency-Key": "revision-123", "X-Governance-Approval": "gvr_fixture"}})
|
|
92
|
+
assert sync.calls[4] == ("POST", "/v1/missions/start", {"json": REQUEST})
|
|
93
|
+
assert sync.calls[-2][2]["json"]["mission_ids"] == ["ms_fixture"]
|
|
94
|
+
assert sync.calls[-1][2]["json"] == {"parent_passport_token": "fixture", "child_agent_id": "agt_child", "scopes": [{"service_connection_id": "scon_fixture", "scopes": ["read"]}]}
|
|
@@ -46,12 +46,18 @@ EXPECTED_SERVICES = [
|
|
|
46
46
|
"partner_connect", "team", "delivery_methods",
|
|
47
47
|
"detector_config",
|
|
48
48
|
"pii_config",
|
|
49
|
-
"authority",
|
|
49
|
+
"authority", "fleets",
|
|
50
50
|
]
|
|
51
51
|
|
|
52
52
|
# (service, method) pairs that must exist on both clients — the integration-
|
|
53
53
|
# critical surface, incl. the gaps GET-244 closed.
|
|
54
54
|
EXPECTED_METHODS = [
|
|
55
|
+
("fleets", "workload_enrollment_challenge"), ("fleets", "enroll_workload"), ("fleets", "enroll_workload_local"),
|
|
56
|
+
("fleets", "members"), ("fleets", "revoke_member"),
|
|
57
|
+
("fleets", "session"), ("fleets", "enroll_local"),
|
|
58
|
+
("fleets", "list"), ("fleets", "activate"), ("fleets", "revise"),
|
|
59
|
+
("fleets", "revoke"), ("fleets", "enrollment_challenge"), ("fleets", "enroll"),
|
|
60
|
+
("missions", "start"), ("missions", "renew"),
|
|
55
61
|
("inbound_webhooks", "set_provider_signing_secret"),
|
|
56
62
|
("services", "connect"),
|
|
57
63
|
("services", "connect_custom"),
|
|
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
|