badgerflow 0.1.0__py3-none-any.whl

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 (98) hide show
  1. agiel/__init__.py +57 -0
  2. badgerflow/__init__.py +38 -0
  3. badgerflow/auth/__init__.py +26 -0
  4. badgerflow/auth/run_token.py +333 -0
  5. badgerflow/cli/__init__.py +5 -0
  6. badgerflow/cli/main.py +1324 -0
  7. badgerflow/cli/preview.py +494 -0
  8. badgerflow/cli/project.py +198 -0
  9. badgerflow/cli/scaffold.py +270 -0
  10. badgerflow/client/__init__.py +40 -0
  11. badgerflow/client/adapters/__init__.py +0 -0
  12. badgerflow/client/adapters/autogen.py +94 -0
  13. badgerflow/client/adapters/crewai.py +93 -0
  14. badgerflow/client/adapters/langchain.py +85 -0
  15. badgerflow/client/cli/__init__.py +0 -0
  16. badgerflow/client/cli/main.py +327 -0
  17. badgerflow/client/cli/manifest.py +81 -0
  18. badgerflow/client/client.py +153 -0
  19. badgerflow/client/exceptions.py +44 -0
  20. badgerflow/client/resources/__init__.py +0 -0
  21. badgerflow/client/resources/agents.py +149 -0
  22. badgerflow/client/resources/auth.py +53 -0
  23. badgerflow/client/resources/dependencies.py +136 -0
  24. badgerflow/client/resources/guardrail_profiles.py +226 -0
  25. badgerflow/client/resources/guardrails.py +137 -0
  26. badgerflow/client/resources/models.py +59 -0
  27. badgerflow/client/resources/namespaces.py +58 -0
  28. badgerflow/client/resources/rag.py +43 -0
  29. badgerflow/client/resources/releases.py +95 -0
  30. badgerflow/client/resources/webhooks.py +57 -0
  31. badgerflow/client/resources/workflows.py +60 -0
  32. badgerflow/connectors/__init__.py +13 -0
  33. badgerflow/connectors/client.py +69 -0
  34. badgerflow/contract/README.md +72 -0
  35. badgerflow/contract/__init__.py +70 -0
  36. badgerflow/contract/constants.py +258 -0
  37. badgerflow/contract/event.schema.json +293 -0
  38. badgerflow/contract/examples/event-complete.json +10 -0
  39. badgerflow/contract/examples/event-pause_review.json +34 -0
  40. badgerflow/contract/examples/event-policy_violation.json +11 -0
  41. badgerflow/contract/examples/event-step_completed.json +23 -0
  42. badgerflow/contract/examples/event-step_started.json +12 -0
  43. badgerflow/contract/examples/invoke-request.json +21 -0
  44. badgerflow/contract/examples/invoke-response-completed.json +7 -0
  45. badgerflow/contract/examples/invoke-response-failed.json +7 -0
  46. badgerflow/contract/examples/invoke-response-paused.json +32 -0
  47. badgerflow/contract/examples/ir.hash +1 -0
  48. badgerflow/contract/examples/ir.json +131 -0
  49. badgerflow/contract/examples/manifest.json +198 -0
  50. badgerflow/contract/hash.py +161 -0
  51. badgerflow/contract/invoke-request.schema.json +95 -0
  52. badgerflow/contract/invoke-response.schema.json +56 -0
  53. badgerflow/contract/ir.schema.json +132 -0
  54. badgerflow/contract/manifest.schema.json +334 -0
  55. badgerflow/contract/pause.py +92 -0
  56. badgerflow/contract/validate.py +124 -0
  57. badgerflow/events/__init__.py +42 -0
  58. badgerflow/events/emitter.py +612 -0
  59. badgerflow/events/steps.py +309 -0
  60. badgerflow/guardrails/__init__.py +32 -0
  61. badgerflow/guardrails/client.py +201 -0
  62. badgerflow/knowledge/__init__.py +12 -0
  63. badgerflow/knowledge/client.py +221 -0
  64. badgerflow/langgraph/__init__.py +51 -0
  65. badgerflow/langgraph/agent.py +396 -0
  66. badgerflow/langgraph/callback.py +243 -0
  67. badgerflow/langgraph/chat.py +131 -0
  68. badgerflow/langgraph/checkpoint.py +155 -0
  69. badgerflow/langgraph/compile.py +479 -0
  70. badgerflow/langgraph/uses.py +139 -0
  71. badgerflow/llm/__init__.py +15 -0
  72. badgerflow/llm/client.py +425 -0
  73. badgerflow/ontology/__init__.py +35 -0
  74. badgerflow/ontology/client.py +218 -0
  75. badgerflow/otel/__init__.py +73 -0
  76. badgerflow/otel/logs.py +162 -0
  77. badgerflow/otel/metrics.py +355 -0
  78. badgerflow/otel/scope.py +185 -0
  79. badgerflow/otel/spans.py +392 -0
  80. badgerflow/otel/tracing.py +242 -0
  81. badgerflow/procode/__init__.py +64 -0
  82. badgerflow/procode/app.py +1131 -0
  83. badgerflow/procode/clients.py +43 -0
  84. badgerflow/procode/config.py +154 -0
  85. badgerflow/procode/context.py +274 -0
  86. badgerflow/procode/current.py +59 -0
  87. badgerflow/procode/errors.py +127 -0
  88. badgerflow/procode/stepctx.py +82 -0
  89. badgerflow/procode/transport.py +129 -0
  90. badgerflow/tools/__init__.py +13 -0
  91. badgerflow/tools/client.py +128 -0
  92. badgerflow-0.1.0.dist-info/METADATA +210 -0
  93. badgerflow-0.1.0.dist-info/RECORD +98 -0
  94. badgerflow-0.1.0.dist-info/WHEEL +5 -0
  95. badgerflow-0.1.0.dist-info/entry_points.txt +3 -0
  96. badgerflow-0.1.0.dist-info/licenses/LICENSE +202 -0
  97. badgerflow-0.1.0.dist-info/licenses/NOTICE +11 -0
  98. badgerflow-0.1.0.dist-info/top_level.txt +2 -0
agiel/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """
2
+ ``agiel`` — deprecated import path for :mod:`badgerflow.client`.
3
+
4
+ The ``agiel`` distribution moved into the ``badgerflow`` package as
5
+ ``badgerflow.client``. This shim keeps ``from agiel import AgiellClient`` (and
6
+ the resource classes and exceptions) working for one release cycle so existing
7
+ scripts, generated exports and docs do not break at import time.
8
+
9
+ It warns once on import. New code should import from ``badgerflow.client``.
10
+ """
11
+
12
+ import warnings
13
+
14
+ from badgerflow import __version__
15
+ from badgerflow.client import (
16
+ AgiellClient,
17
+ AgiellError,
18
+ AuthError,
19
+ ConflictError,
20
+ NotFoundError,
21
+ ServerError,
22
+ ValidationError,
23
+ )
24
+ from badgerflow.client.resources.agents import AgentClient
25
+ from badgerflow.client.resources.auth import AuthClient
26
+ from badgerflow.client.resources.guardrail_profiles import GuardrailProfilesClient
27
+ from badgerflow.client.resources.guardrails import GuardrailsClient
28
+ from badgerflow.client.resources.models import ModelRegistryClient
29
+ from badgerflow.client.resources.namespaces import NamespaceClient
30
+ from badgerflow.client.resources.rag import RAGClient
31
+ from badgerflow.client.resources.webhooks import WebhookClient
32
+
33
+ warnings.warn(
34
+ "the 'agiel' package is deprecated; import from 'badgerflow.client' instead "
35
+ "(e.g. 'from badgerflow.client import AgiellClient')",
36
+ DeprecationWarning,
37
+ stacklevel=2,
38
+ )
39
+
40
+ __all__ = [
41
+ "__version__",
42
+ "AgiellClient",
43
+ "AgiellError",
44
+ "AuthError",
45
+ "ConflictError",
46
+ "NotFoundError",
47
+ "ServerError",
48
+ "ValidationError",
49
+ "AgentClient",
50
+ "AuthClient",
51
+ "GuardrailProfilesClient",
52
+ "GuardrailsClient",
53
+ "ModelRegistryClient",
54
+ "NamespaceClient",
55
+ "RAGClient",
56
+ "WebhookClient",
57
+ ]
badgerflow/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """
2
+ badgerflow — the BadgerFlow Python SDK
3
+ ======================================
4
+
5
+ One installable package with two halves:
6
+
7
+ * ``badgerflow.client`` — the async REST client (``AgiellClient``) and the
8
+ ``agiel`` CLI that used to ship as the ``agiel`` distribution. The old
9
+ ``agiel`` import path still works through a deprecated shim.
10
+ * ``badgerflow.procode`` and friends — the pro-code surface (a governed
11
+ ``@app.run`` server, ``Context``, the LangGraph bridge, the ``bf`` CLI).
12
+ These packages are skeletons filled in by later work packages; each one's
13
+ docstring names the WP that owns it.
14
+
15
+ The package never imports the platform's ``libs/`` or ``services/``. The
16
+ constants both sides must agree on are vendored under ``badgerflow.contract``
17
+ and pinned to ``CONTRACT_VERSION``.
18
+
19
+ This module stays import-light on purpose: ``import badgerflow`` must not
20
+ pull in httpx, typer or any optional framework, so the version is the only
21
+ thing defined eagerly. ``badgerflow.current()`` — the running invocation's
22
+ ``Context`` — is resolved lazily on first access so that the server package
23
+ (fastapi, prometheus_client) loads only for code that actually calls it.
24
+ """
25
+
26
+ from typing import Any
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ __all__ = ["__version__", "current"]
31
+
32
+
33
+ def __getattr__(name: str) -> Any:
34
+ if name == "current":
35
+ from .procode.current import current
36
+
37
+ return current
38
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,26 @@
1
+ """badgerflow.auth — run-token verification (JWKS) for the ``/invocations`` bearer.
2
+
3
+ Vendors the platform's Ed25519/JWKS verification (``run_token.py``); never
4
+ imports the platform's ``libs/``. Actor/exec token *forwarding* lives with the
5
+ governed clients (S0.4) — the server here only carries them into ``Context``.
6
+ """
7
+
8
+ from .run_token import (
9
+ RunTokenClaims,
10
+ RunTokenError,
11
+ RunTokenVerifier,
12
+ Verifier,
13
+ auth_disabled_from_env,
14
+ keys_from_jwks,
15
+ verifier_from_env,
16
+ )
17
+
18
+ __all__ = [
19
+ "RunTokenClaims",
20
+ "RunTokenError",
21
+ "RunTokenVerifier",
22
+ "Verifier",
23
+ "auth_disabled_from_env",
24
+ "keys_from_jwks",
25
+ "verifier_from_env",
26
+ ]
@@ -0,0 +1,333 @@
1
+ """Verify the run token that arrives as the bearer of every ``/invocations``.
2
+
3
+ The platform mints run tokens through ``libs/common/session_jwt.py`` (Ed25519,
4
+ ``alg=EdDSA``, a ``kid`` header) and publishes the public keys as a JWKS at
5
+ ``{BADGERFLOW_JWKS_URL}`` — ``http://auth-service:8090/v1/auth/jwks`` inside a
6
+ cluster. This module *vendors* that verification: it never imports the
7
+ platform, it reads the same JWKS any other verifier reads, and it checks the
8
+ four facts the contract fixes (``constants.py``, "Run token"):
9
+
10
+ * signature, against the key the ``kid`` names;
11
+ * ``iss == RUN_TOKEN_ISSUER`` and ``tok == RUN_TOKEN_TYPE`` — a session or
12
+ actor token signed by the same key must not verify as a run token;
13
+ * ``aud`` contains ``procode:<workflow_id>`` — a run token for a *different*
14
+ pro-code workflow, or a Tier-2 token whose audience is the platform's own
15
+ services, is refused;
16
+ * ``exp`` in the future (60 s leeway, the platform's own tolerance is 30 s).
17
+
18
+ Two more checks are the caller's: ``jti == body.run_id`` is done by the server
19
+ because it needs the body, and ``wf``, the workflow claim the platform stamps on
20
+ pro-code tokens, is checked *when present* — it was introduced on a separate
21
+ platform branch and a token without it is still a valid token for the audience
22
+ it names.
23
+
24
+ Key handling copies ``libs/common/signing.py``: keys are looked up by ``kid``,
25
+ cached for ``cache_ttl`` seconds, and a ``kid`` the cache does not know
26
+ triggers exactly one refetch before the token is refused — that is what makes
27
+ a key rotation invisible to a running service without letting a forged ``kid``
28
+ turn every request into a JWKS fetch.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import base64
35
+ import logging
36
+ import os
37
+ import time
38
+ from dataclasses import dataclass, field
39
+ from datetime import datetime, timezone
40
+ from typing import Any, Mapping, Protocol
41
+
42
+ import httpx
43
+ import jwt
44
+
45
+ from badgerflow.contract.constants import RUN_TOKEN_ISSUER, RUN_TOKEN_TYPE, procode_audience
46
+
47
+ log = logging.getLogger("badgerflow.auth")
48
+
49
+ #: Leeway on ``exp``. Generous next to the platform's 30 s because a customer's
50
+ #: cluster and the platform's are two clocks that nobody syncs on purpose.
51
+ EXP_LEEWAY_SECONDS = 60
52
+
53
+ #: Environment the server reads when the caller passes nothing explicit.
54
+ ENV_JWKS_URL = "BADGERFLOW_JWKS_URL"
55
+ ENV_WORKFLOW_ID = "BADGERFLOW_WORKFLOW_ID"
56
+ ENV_AUTH_DISABLED = "BADGERFLOW_AUTH_DISABLED"
57
+ ENV_ENV = "BADGERFLOW_ENV"
58
+
59
+
60
+ class RunTokenError(Exception):
61
+ """The bearer is not a run token this service accepts. The message is safe
62
+ to log; the server answers 401 ``unauthorized`` without echoing it."""
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class RunTokenClaims:
67
+ """What a verified run token asserts. ``run_id`` is the ``jti`` — the binding
68
+ the server compares to ``body.run_id`` — and ``workflow_id`` is the id the
69
+ verifier was configured with (equal to the token's ``wf`` when it has one)."""
70
+
71
+ run_id: str
72
+ namespace: str
73
+ trust_tier: str
74
+ workflow_id: str
75
+ expires_at: datetime
76
+ subject: str = ""
77
+ audience: tuple[str, ...] = ()
78
+ claims: Mapping[str, Any] = field(default_factory=dict, repr=False)
79
+
80
+
81
+ class Verifier(Protocol):
82
+ """What the server needs from a verifier; :class:`RunTokenVerifier` is the
83
+ real one, tests inject a stand-in with the same single method."""
84
+
85
+ async def verify(self, token: str) -> RunTokenClaims: ...
86
+
87
+
88
+ def _b64url_decode(data: str) -> bytes:
89
+ return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
90
+
91
+
92
+ def _public_key_from_jwk(jwk: Mapping[str, Any]) -> Any:
93
+ """An ``Ed25519PublicKey`` from an OKP JWK; ``None`` for any other key type.
94
+
95
+ The platform's JWKS carries ``{"kty": "OKP", "crv": "Ed25519", "kid", "x"}``
96
+ (``libs/common/signing.get_public_key_jwk``). Keys of another type are
97
+ skipped rather than refused so a JWKS that one day also lists an RSA key
98
+ keeps working for the Ed25519 tokens this verifier is for.
99
+ """
100
+ if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not jwk.get("x"):
101
+ return None
102
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
103
+
104
+ try:
105
+ return Ed25519PublicKey.from_public_bytes(_b64url_decode(str(jwk["x"])))
106
+ except (ValueError, TypeError):
107
+ return None
108
+
109
+
110
+ def keys_from_jwks(jwks: Mapping[str, Any]) -> dict[str, list[Any]]:
111
+ """``kid -> [public keys]`` for every Ed25519 key in a JWKS document.
112
+
113
+ A list per ``kid`` because the platform may publish a rotation predecessor
114
+ under the same id for the length of a rollout (``_public_keys_for_kid``);
115
+ verification tries each.
116
+ """
117
+ out: dict[str, list[Any]] = {}
118
+ for entry in jwks.get("keys") or ():
119
+ if not isinstance(entry, Mapping):
120
+ continue
121
+ key = _public_key_from_jwk(entry)
122
+ if key is None:
123
+ continue
124
+ out.setdefault(str(entry.get("kid", "")), []).append(key)
125
+ return out
126
+
127
+
128
+ class RunTokenVerifier:
129
+ """Verify run tokens for one pro-code workflow against the platform's JWKS.
130
+
131
+ ``jwks`` injects a static JWKS document (unit tests, air-gapped dev); with
132
+ it no HTTP fetch happens and a ``kid`` miss is simply a miss. Otherwise the
133
+ document is fetched from ``jwks_url`` on first use, re-fetched after
134
+ ``cache_ttl`` seconds, and re-fetched once, immediately, on a ``kid`` the
135
+ cache does not hold. Concurrent misses share one fetch.
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ jwks_url: str | None,
141
+ workflow_id: str,
142
+ *,
143
+ cache_ttl: float = 300.0,
144
+ jwks: Mapping[str, Any] | None = None,
145
+ http_client: httpx.AsyncClient | None = None,
146
+ timeout: float = 5.0,
147
+ ):
148
+ if not workflow_id:
149
+ raise ValueError("RunTokenVerifier needs the workflow id the audience is checked against")
150
+ if jwks is None and not jwks_url:
151
+ raise ValueError("RunTokenVerifier needs a jwks_url (or an injected jwks document)")
152
+ self.jwks_url = jwks_url
153
+ self.workflow_id = workflow_id
154
+ self.audience = procode_audience(workflow_id)
155
+ self.cache_ttl = cache_ttl
156
+ self._static = jwks is not None
157
+ self._keys: dict[str, list[Any]] = keys_from_jwks(jwks) if jwks is not None else {}
158
+ self._fetched_at: float = time.monotonic() if jwks is not None else float("-inf")
159
+ self._client = http_client
160
+ self._timeout = timeout
161
+ self._lock = asyncio.Lock()
162
+ self.fetch_count = 0
163
+
164
+ # ── JWKS ─────────────────────────────────────────────────────────────────
165
+
166
+ async def _fetch(self) -> None:
167
+ assert self.jwks_url
168
+ client = self._client
169
+ try:
170
+ if client is None:
171
+ async with httpx.AsyncClient(timeout=self._timeout) as own:
172
+ resp = await own.get(self.jwks_url)
173
+ else:
174
+ resp = await client.get(self.jwks_url)
175
+ resp.raise_for_status()
176
+ doc = resp.json()
177
+ except (httpx.HTTPError, ValueError) as exc:
178
+ raise RunTokenError(f"JWKS fetch from {self.jwks_url} failed: {exc}") from exc
179
+ if not isinstance(doc, dict):
180
+ raise RunTokenError("JWKS document is not an object")
181
+ self._keys = keys_from_jwks(doc)
182
+ self._fetched_at = time.monotonic()
183
+ self.fetch_count += 1
184
+ log.debug("badgerflow: loaded %d signing key ids from %s", len(self._keys), self.jwks_url)
185
+
186
+ async def _keys_for(self, kid: str) -> list[Any]:
187
+ """Keys for *kid*, refreshing the cache when it is stale or does not
188
+ know the id. Refresh happens at most once per call, so a token naming
189
+ an unknown ``kid`` costs one fetch, not a retry storm."""
190
+ if self._static:
191
+ return self._keys.get(kid, [])
192
+ async with self._lock:
193
+ stale = time.monotonic() - self._fetched_at > self.cache_ttl
194
+ if stale or kid not in self._keys:
195
+ await self._fetch()
196
+ return self._keys.get(kid, [])
197
+
198
+ # ── verification ─────────────────────────────────────────────────────────
199
+
200
+ async def verify(self, token: str) -> RunTokenClaims:
201
+ """Return the claims of a run token valid for this workflow, or raise
202
+ :class:`RunTokenError`. Every rejection reason is one exception type so
203
+ the server can map all of them to 401 without inspecting them."""
204
+ if not token:
205
+ raise RunTokenError("missing bearer token")
206
+ try:
207
+ header = jwt.get_unverified_header(token)
208
+ except jwt.PyJWTError as exc:
209
+ raise RunTokenError(f"malformed token: {exc}") from exc
210
+ if header.get("alg") not in ("EdDSA", "Ed25519"):
211
+ raise RunTokenError(f"unsupported alg {header.get('alg')!r}")
212
+ kid = str(header.get("kid") or "")
213
+ candidates = await self._keys_for(kid)
214
+ if not candidates:
215
+ raise RunTokenError(f"no signing key for kid={kid!r}")
216
+
217
+ payload: dict[str, Any] | None = None
218
+ last: Exception | None = None
219
+ for key in candidates:
220
+ try:
221
+ payload = jwt.decode(
222
+ token,
223
+ key=key,
224
+ algorithms=["EdDSA"],
225
+ audience=self.audience,
226
+ issuer=RUN_TOKEN_ISSUER,
227
+ leeway=EXP_LEEWAY_SECONDS,
228
+ options={"require": ["exp", "iss", "aud", "jti"]},
229
+ )
230
+ break
231
+ except jwt.InvalidSignatureError as exc:
232
+ last = exc # a rotation predecessor under the same kid may still match
233
+ except jwt.PyJWTError as exc:
234
+ raise RunTokenError(f"run token rejected: {exc}") from exc
235
+ if payload is None:
236
+ raise RunTokenError(f"run token rejected: {last}")
237
+
238
+ if payload.get("tok") != RUN_TOKEN_TYPE:
239
+ raise RunTokenError("not a run token (tok != run)")
240
+ wf = payload.get("wf")
241
+ if wf is not None and str(wf) != self.workflow_id:
242
+ raise RunTokenError(f"run token is for workflow {wf!r}, not {self.workflow_id!r}")
243
+ run_id = str(payload.get("jti") or "")
244
+ if not run_id:
245
+ raise RunTokenError("run token has an empty jti")
246
+
247
+ aud = payload.get("aud") or ()
248
+ return RunTokenClaims(
249
+ run_id=run_id,
250
+ namespace=str(payload.get("ns") or ""),
251
+ trust_tier=str(payload.get("trust_tier") or ""),
252
+ workflow_id=self.workflow_id,
253
+ expires_at=datetime.fromtimestamp(int(payload["exp"]), tz=timezone.utc),
254
+ subject=str(payload.get("sub") or ""),
255
+ audience=(aud,) if isinstance(aud, str) else tuple(str(a) for a in aud),
256
+ claims=dict(payload),
257
+ )
258
+
259
+
260
+ def _truthy(value: str | None) -> bool:
261
+ return (value or "").strip().lower() in ("1", "true", "yes", "on")
262
+
263
+
264
+ def auth_disabled_from_env(environ: Mapping[str, str] | None = None) -> bool:
265
+ """``True`` only when ``BADGERFLOW_AUTH_DISABLED`` is set *and*
266
+ ``BADGERFLOW_ENV=dev``. Set outside dev it raises rather than being ignored:
267
+ a deployment that asked for no auth and silently kept it would be as
268
+ surprising as one that silently dropped it.
269
+ """
270
+ env = os.environ if environ is None else environ
271
+ if not _truthy(env.get(ENV_AUTH_DISABLED)):
272
+ return False
273
+ if (env.get(ENV_ENV) or "").strip().lower() != "dev":
274
+ raise RuntimeError(
275
+ f"{ENV_AUTH_DISABLED} is only honoured with {ENV_ENV}=dev; "
276
+ "refusing to start without run-token verification"
277
+ )
278
+ log.warning(
279
+ "badgerflow: %s=true — run tokens are NOT verified; every /invocations caller is "
280
+ "trusted. This is a dev-only setting.",
281
+ ENV_AUTH_DISABLED,
282
+ )
283
+ return True
284
+
285
+
286
+ def verifier_from_env(
287
+ workflow_id: str | None = None,
288
+ jwks_url: str | None = None,
289
+ *,
290
+ environ: Mapping[str, str] | None = None,
291
+ cache_ttl: float = 300.0,
292
+ ) -> RunTokenVerifier:
293
+ """A :class:`RunTokenVerifier` from explicit values or ``BADGERFLOW_JWKS_URL``
294
+ / ``BADGERFLOW_WORKFLOW_ID``. Raises ``RuntimeError`` naming the missing
295
+ variable — the server calls this at build so a misconfigured deployment
296
+ fails at startup, not on its first request."""
297
+ env = os.environ if environ is None else environ
298
+ wf = workflow_id or env.get(ENV_WORKFLOW_ID) or ""
299
+ url = jwks_url or env.get(ENV_JWKS_URL) or ""
300
+ if not wf:
301
+ raise RuntimeError(f"{ENV_WORKFLOW_ID} is not set (registration sets it)")
302
+ if not url:
303
+ raise RuntimeError(f"{ENV_JWKS_URL} is not set (e.g. http://auth-service:8090/v1/auth/jwks)")
304
+ return RunTokenVerifier(url, wf, cache_ttl=cache_ttl)
305
+
306
+
307
+ def unverified_claims(token: str | None) -> dict[str, Any]:
308
+ """The payload of *token* WITHOUT verification — for the dev escape hatch
309
+ only, so a locally minted token still populates the context. Never
310
+ authentication."""
311
+ if not token:
312
+ return {}
313
+ try:
314
+ return dict(jwt.decode(token, options={"verify_signature": False}))
315
+ except jwt.PyJWTError:
316
+ return {}
317
+
318
+
319
+ __all__ = [
320
+ "ENV_AUTH_DISABLED",
321
+ "ENV_ENV",
322
+ "ENV_JWKS_URL",
323
+ "ENV_WORKFLOW_ID",
324
+ "EXP_LEEWAY_SECONDS",
325
+ "RunTokenClaims",
326
+ "RunTokenError",
327
+ "RunTokenVerifier",
328
+ "Verifier",
329
+ "auth_disabled_from_env",
330
+ "keys_from_jwks",
331
+ "unverified_claims",
332
+ "verifier_from_env",
333
+ ]
@@ -0,0 +1,5 @@
1
+ """badgerflow.cli — the ``bf`` CLI: ``init``, ``dev``, ``validate``, ``register``,
2
+ ``status``, ``version`` (S0.5). S2.1 adds ``release submit | status``, ``validate
3
+ --against`` and ``compile --check``. ``project.py`` is the ``badgerflow.yaml`` +
4
+ ``agent.py`` loader, ``scaffold.py`` the ``bf init`` templates. The legacy ``agiel``
5
+ CLI lives in ``badgerflow.client.cli``."""