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.
Files changed (89) hide show
  1. {getstack-1.3.0 → getstack-1.4.0}/.gitignore +3 -0
  2. {getstack-1.3.0 → getstack-1.4.0}/PKG-INFO +44 -40
  3. {getstack-1.3.0 → getstack-1.4.0}/README.md +43 -39
  4. {getstack-1.3.0 → getstack-1.4.0}/pyproject.toml +1 -1
  5. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/__init__.py +55 -36
  6. getstack-1.4.0/src/getstack/_async/fleets.py +82 -0
  7. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/missions.py +17 -0
  8. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/passports.py +7 -5
  9. getstack-1.4.0/src/getstack/_fleet_key.py +44 -0
  10. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/agent_auth.py +9 -1
  11. getstack-1.4.0/src/getstack/fleets.py +82 -0
  12. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/missions.py +17 -0
  13. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/passports.py +27 -4
  14. getstack-1.4.0/tests/test_fleets.py +94 -0
  15. {getstack-1.3.0 → getstack-1.4.0}/tests/test_parity.py +7 -1
  16. {getstack-1.3.0 → getstack-1.4.0}/CLAUDE.md +0 -0
  17. {getstack-1.3.0 → getstack-1.4.0}/LICENSE +0 -0
  18. {getstack-1.3.0 → getstack-1.4.0}/scripts/verify_sign_claim_crosslang.py +0 -0
  19. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/__init__.py +0 -0
  20. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/agents.py +0 -0
  21. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/audit.py +0 -0
  22. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/authority.py +0 -0
  23. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/credentials.py +0 -0
  24. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/delivery_methods.py +0 -0
  25. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/detector_config.py +0 -0
  26. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/dropoffs.py +0 -0
  27. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/evidence_packs.py +0 -0
  28. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/identity.py +0 -0
  29. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/inbound_webhooks.py +0 -0
  30. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/intents.py +0 -0
  31. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/llm.py +0 -0
  32. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/notifications.py +0 -0
  33. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/partner_connect.py +0 -0
  34. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/partner_tenants.py +0 -0
  35. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/passport_session.py +0 -0
  36. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/pii_config.py +0 -0
  37. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/proxy.py +0 -0
  38. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/reviews.py +0 -0
  39. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/scan.py +0 -0
  40. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/security_events.py +0 -0
  41. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/services.py +0 -0
  42. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/skills.py +0 -0
  43. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/team.py +0 -0
  44. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_async/usage.py +0 -0
  45. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/_authority_request.py +0 -0
  46. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/agents.py +0 -0
  47. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/audit.py +0 -0
  48. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/auth.py +0 -0
  49. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/authority.py +0 -0
  50. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/browser_bootstrap.py +0 -0
  51. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/client.py +0 -0
  52. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/credentials.py +0 -0
  53. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/delivery_methods.py +0 -0
  54. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/detector_config.py +0 -0
  55. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/dropoffs.py +0 -0
  56. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/errors.py +0 -0
  57. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/evidence_packs.py +0 -0
  58. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/identity.py +0 -0
  59. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/inbound_webhooks.py +0 -0
  60. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/intents.py +0 -0
  61. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/llm.py +0 -0
  62. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/notifications.py +0 -0
  63. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/partner_connect.py +0 -0
  64. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/partner_tenants.py +0 -0
  65. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/passport_header_verify.py +0 -0
  66. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/passport_session.py +0 -0
  67. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/pii_config.py +0 -0
  68. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/proxy.py +0 -0
  69. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/py.typed +0 -0
  70. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/reviews.py +0 -0
  71. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/scan.py +0 -0
  72. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/security_events.py +0 -0
  73. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/services.py +0 -0
  74. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/sign_claim.py +0 -0
  75. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/skills.py +0 -0
  76. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/team.py +0 -0
  77. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/types.py +0 -0
  78. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/usage.py +0 -0
  79. {getstack-1.3.0 → getstack-1.4.0}/src/getstack/verify_offline.py +0 -0
  80. {getstack-1.3.0 → getstack-1.4.0}/tests/test_authority.py +0 -0
  81. {getstack-1.3.0 → getstack-1.4.0}/tests/test_delivery_methods.py +0 -0
  82. {getstack-1.3.0 → getstack-1.4.0}/tests/test_dropoffs.py +0 -0
  83. {getstack-1.3.0 → getstack-1.4.0}/tests/test_errors.py +0 -0
  84. {getstack-1.3.0 → getstack-1.4.0}/tests/test_evidence_packs.py +0 -0
  85. {getstack-1.3.0 → getstack-1.4.0}/tests/test_identity_requirements.py +0 -0
  86. {getstack-1.3.0 → getstack-1.4.0}/tests/test_inbound_webhooks.py +0 -0
  87. {getstack-1.3.0 → getstack-1.4.0}/tests/test_missions.py +0 -0
  88. {getstack-1.3.0 → getstack-1.4.0}/tests/test_partner_signing.py +0 -0
  89. {getstack-1.3.0 → getstack-1.4.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: 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
- 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 = "1.4.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(
@@ -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
@@ -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
- path.write_text(json.dumps(payload, indent=2), encoding="utf8")
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] = {"parent_token": parent_token, "child_agent_id": child_agent_id}
378
- if scopes is not None:
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