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.
Files changed (90) hide show
  1. {getstack-1.3.0 → getstack-2.0.0}/.gitignore +3 -0
  2. {getstack-1.3.0 → getstack-2.0.0}/PKG-INFO +44 -40
  3. {getstack-1.3.0 → getstack-2.0.0}/README.md +43 -39
  4. {getstack-1.3.0 → getstack-2.0.0}/pyproject.toml +1 -1
  5. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/__init__.py +55 -36
  6. getstack-2.0.0/src/getstack/_async/fleets.py +82 -0
  7. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/missions.py +17 -0
  8. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/partner_tenants.py +17 -6
  9. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/passports.py +7 -5
  10. getstack-2.0.0/src/getstack/_fleet_key.py +44 -0
  11. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/agent_auth.py +9 -1
  12. getstack-2.0.0/src/getstack/fleets.py +82 -0
  13. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/missions.py +17 -0
  14. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/partner_connect.py +3 -2
  15. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/partner_tenants.py +33 -9
  16. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/passports.py +27 -4
  17. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/proxy.py +3 -1
  18. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/scan.py +3 -1
  19. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/usage.py +4 -0
  20. getstack-2.0.0/tests/test_fleets.py +94 -0
  21. {getstack-1.3.0 → getstack-2.0.0}/tests/test_parity.py +7 -1
  22. getstack-2.0.0/tests/test_partner_signing.py +69 -0
  23. getstack-1.3.0/tests/test_partner_signing.py +0 -36
  24. {getstack-1.3.0 → getstack-2.0.0}/CLAUDE.md +0 -0
  25. {getstack-1.3.0 → getstack-2.0.0}/LICENSE +0 -0
  26. {getstack-1.3.0 → getstack-2.0.0}/scripts/verify_sign_claim_crosslang.py +0 -0
  27. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/__init__.py +0 -0
  28. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/agents.py +0 -0
  29. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/audit.py +0 -0
  30. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/authority.py +0 -0
  31. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/credentials.py +0 -0
  32. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/delivery_methods.py +0 -0
  33. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/detector_config.py +0 -0
  34. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/dropoffs.py +0 -0
  35. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/evidence_packs.py +0 -0
  36. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/identity.py +0 -0
  37. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/inbound_webhooks.py +0 -0
  38. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/intents.py +0 -0
  39. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/llm.py +0 -0
  40. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/notifications.py +0 -0
  41. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/partner_connect.py +0 -0
  42. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/passport_session.py +0 -0
  43. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/pii_config.py +0 -0
  44. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/proxy.py +0 -0
  45. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/reviews.py +0 -0
  46. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/scan.py +0 -0
  47. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/security_events.py +0 -0
  48. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/services.py +0 -0
  49. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/skills.py +0 -0
  50. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/team.py +0 -0
  51. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_async/usage.py +0 -0
  52. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/_authority_request.py +0 -0
  53. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/agents.py +0 -0
  54. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/audit.py +0 -0
  55. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/auth.py +0 -0
  56. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/authority.py +0 -0
  57. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/browser_bootstrap.py +0 -0
  58. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/client.py +0 -0
  59. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/credentials.py +0 -0
  60. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/delivery_methods.py +0 -0
  61. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/detector_config.py +0 -0
  62. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/dropoffs.py +0 -0
  63. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/errors.py +0 -0
  64. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/evidence_packs.py +0 -0
  65. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/identity.py +0 -0
  66. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/inbound_webhooks.py +0 -0
  67. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/intents.py +0 -0
  68. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/llm.py +0 -0
  69. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/notifications.py +0 -0
  70. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/passport_header_verify.py +0 -0
  71. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/passport_session.py +0 -0
  72. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/pii_config.py +0 -0
  73. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/py.typed +0 -0
  74. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/reviews.py +0 -0
  75. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/security_events.py +0 -0
  76. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/services.py +0 -0
  77. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/sign_claim.py +0 -0
  78. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/skills.py +0 -0
  79. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/team.py +0 -0
  80. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/types.py +0 -0
  81. {getstack-1.3.0 → getstack-2.0.0}/src/getstack/verify_offline.py +0 -0
  82. {getstack-1.3.0 → getstack-2.0.0}/tests/test_authority.py +0 -0
  83. {getstack-1.3.0 → getstack-2.0.0}/tests/test_delivery_methods.py +0 -0
  84. {getstack-1.3.0 → getstack-2.0.0}/tests/test_dropoffs.py +0 -0
  85. {getstack-1.3.0 → getstack-2.0.0}/tests/test_errors.py +0 -0
  86. {getstack-1.3.0 → getstack-2.0.0}/tests/test_evidence_packs.py +0 -0
  87. {getstack-1.3.0 → getstack-2.0.0}/tests/test_identity_requirements.py +0 -0
  88. {getstack-1.3.0 → getstack-2.0.0}/tests/test_inbound_webhooks.py +0 -0
  89. {getstack-1.3.0 → getstack-2.0.0}/tests/test_missions.py +0 -0
  90. {getstack-1.3.0 → getstack-2.0.0}/tests/test_sign_claim.py +0 -0
@@ -89,3 +89,6 @@ scratchpad/
89
89
 
90
90
  # Local venv for the AgentDojo benchmark harness (never commit)
91
91
  .venv-agentdojo/
92
+
93
+ # Isolated test-only LangChain, CrewAI and AutoGen verification environment.
94
+ .tmp/fleet-framework-venv/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: getstack
3
- Version: 1.3.0
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
- from getstack import Stack
48
-
49
- # Zero-arg constructor reads ~/.stack/credentials.json (OAuth refresh token).
50
- # Falls back to STACK_API_KEY env var, then constructor api_key= for CI.
51
- stack = Stack()
52
-
53
- # 1. Register an agent (one-time)
54
- agent = stack.agents.register("my-agent", accountability_mode="enforced")
55
-
56
- # 2. In your agent runtime, switch to per-agent keypair mode
57
- agent_stack = Stack(agent_id=agent.id)
58
- # First run: generates an Ed25519 keypair locally + enrolls the public
59
- # half via /v1/agents/<id>/enroll. Persisted at ~/.stack/agents/<id>.json
60
- # (mode 0600). Every subsequent call signs a fresh 60-second JWT.
61
-
62
- # 3. Run a mission with automatic checkpoints and checkout
63
- with agent_stack.passports.mission(
64
- agent_id=agent.id,
65
- intent="Process invoices from Slack",
66
- services=["slack", "stripe"],
67
- checkpoint_interval="5m",
68
- ) as mission:
69
- mission.log("slack", "read_channel", "#invoices")
70
- mission.log("stripe", "create_invoice")
71
- # Checkpoints are submitted automatically on schedule
72
- # Checkout is submitted automatically when the block exits
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
- ## Continuous missions (24/7 agents)
109
+ ## Renew a Mission's Passport
98
110
 
99
111
  ```python
100
- for mission in stack.passports.continuous_mission(
101
- agent_id=agent.id,
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
- from getstack import Stack
21
-
22
- # Zero-arg constructor reads ~/.stack/credentials.json (OAuth refresh token).
23
- # Falls back to STACK_API_KEY env var, then constructor api_key= for CI.
24
- stack = Stack()
25
-
26
- # 1. Register an agent (one-time)
27
- agent = stack.agents.register("my-agent", accountability_mode="enforced")
28
-
29
- # 2. In your agent runtime, switch to per-agent keypair mode
30
- agent_stack = Stack(agent_id=agent.id)
31
- # First run: generates an Ed25519 keypair locally + enrolls the public
32
- # half via /v1/agents/<id>/enroll. Persisted at ~/.stack/agents/<id>.json
33
- # (mode 0600). Every subsequent call signs a fresh 60-second JWT.
34
-
35
- # 3. Run a mission with automatic checkpoints and checkout
36
- with agent_stack.passports.mission(
37
- agent_id=agent.id,
38
- intent="Process invoices from Slack",
39
- services=["slack", "stripe"],
40
- checkpoint_interval="5m",
41
- ) as mission:
42
- mission.log("slack", "read_channel", "#invoices")
43
- mission.log("stripe", "create_invoice")
44
- # Checkpoints are submitted automatically on schedule
45
- # Checkout is submitted automatically when the block exits
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
- ## Continuous missions (24/7 agents)
82
+ ## Renew a Mission's Passport
71
83
 
72
84
  ```python
73
- for mission in stack.passports.continuous_mission(
74
- agent_id=agent.id,
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "getstack"
7
- version = "1.3.0"
7
+ version = "2.0.0"
8
8
  description = "Python SDK for STACK — trust infrastructure for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,39 +1,51 @@
1
- """STACK Python SDK — trust infrastructure for AI agents.
2
-
3
- Usage::
4
-
5
- from getstack import Stack
6
-
7
- stack = Stack(api_key="sk_live_...")
8
- agent = stack.agents.register("my-agent")
9
-
10
- with stack.passports.mission(
11
- agent_id=agent.id,
12
- intent="Process invoices",
13
- services=["slack", "stripe"],
14
- ) as mission:
15
- mission.log("slack", "read_channel", "#invoices")
16
- mission.log("stripe", "create_invoice")
17
-
18
- For OAuth auth::
19
-
20
- stack = Stack.from_oauth(
21
- client_id="...",
22
- client_secret="...",
23
- access_token="...",
24
- refresh_token="...",
25
- )
26
-
27
- For async usage::
28
-
29
- from getstack import AsyncStack
30
-
31
- stack = AsyncStack(api_key="sk_live_...")
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
- __version__ = "0.2.0"
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: static_auth.get_headers()["Authorization"].split(" ", 1)[1],
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: static_auth.get_headers()["Authorization"].split(" ", 1)[1],
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 (Phase 5.6)."""
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] = {"parent_token": parent_token, "child_agent_id": child_agent_id}
346
- if scopes is not None:
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