agentplane-runtime 0.0.1__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 (27) hide show
  1. agentplane_runtime-0.0.1/.gitignore +34 -0
  2. agentplane_runtime-0.0.1/PKG-INFO +48 -0
  3. agentplane_runtime-0.0.1/README.md +21 -0
  4. agentplane_runtime-0.0.1/pyproject.toml +40 -0
  5. agentplane_runtime-0.0.1/src/agentplane_runtime/__init__.py +8 -0
  6. agentplane_runtime-0.0.1/src/agentplane_runtime/api.py +232 -0
  7. agentplane_runtime-0.0.1/src/agentplane_runtime/app.py +85 -0
  8. agentplane_runtime-0.0.1/src/agentplane_runtime/auth.py +137 -0
  9. agentplane_runtime-0.0.1/src/agentplane_runtime/db.py +129 -0
  10. agentplane_runtime-0.0.1/src/agentplane_runtime/definitions.py +280 -0
  11. agentplane_runtime-0.0.1/src/agentplane_runtime/engine.py +407 -0
  12. agentplane_runtime-0.0.1/src/agentplane_runtime/llm.py +121 -0
  13. agentplane_runtime-0.0.1/src/agentplane_runtime/registration.py +145 -0
  14. agentplane_runtime-0.0.1/src/agentplane_runtime/resources.py +237 -0
  15. agentplane_runtime-0.0.1/src/agentplane_runtime/secrets.py +45 -0
  16. agentplane_runtime-0.0.1/src/agentplane_runtime/serving.py +383 -0
  17. agentplane_runtime-0.0.1/src/agentplane_runtime/settings.py +31 -0
  18. agentplane_runtime-0.0.1/src/agentplane_runtime/tracing.py +52 -0
  19. agentplane_runtime-0.0.1/src/agentplane_runtime/validation.py +91 -0
  20. agentplane_runtime-0.0.1/src/agentplane_runtime/vector.py +149 -0
  21. agentplane_runtime-0.0.1/tests/conftest.py +93 -0
  22. agentplane_runtime-0.0.1/tests/test_definitions_api.py +212 -0
  23. agentplane_runtime-0.0.1/tests/test_engine.py +122 -0
  24. agentplane_runtime-0.0.1/tests/test_engine_router.py +81 -0
  25. agentplane_runtime-0.0.1/tests/test_resources_api.py +59 -0
  26. agentplane_runtime-0.0.1/tests/test_secrets.py +33 -0
  27. agentplane_runtime-0.0.1/tests/test_serving.py +185 -0
@@ -0,0 +1,34 @@
1
+ # internal working documents — not part of the public repo
2
+ CLAUDE.md
3
+ SPEC.md
4
+ .claude/
5
+
6
+ # python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.egg-info/
10
+ .venv/
11
+ dist/
12
+ build/
13
+
14
+ # tooling caches
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+ .coverage
19
+ htmlcov/
20
+
21
+ # local databases and state
22
+ *.db
23
+ registry.db
24
+ runtime.db
25
+
26
+ # environments / secrets
27
+ .env
28
+ .env.*
29
+
30
+ # editors / OS
31
+ .vscode/
32
+ .idea/
33
+ .DS_Store
34
+ Thumbs.db
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentplane-runtime
3
+ Version: 0.0.1
4
+ Summary: agentplane runtime: definitions, resources, flow execution, A2A + MCP serving
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: a2a-sdk[http-server]<2,>=1.1
8
+ Requires-Dist: agentplane-core<0.1.0,>=0.0.1
9
+ Requires-Dist: agentplane-sdk<0.1.0,>=0.0.1
10
+ Requires-Dist: aiosqlite>=0.20
11
+ Requires-Dist: cryptography>=43
12
+ Requires-Dist: fastapi>=0.115
13
+ Requires-Dist: fastmcp==3.4.4
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: langgraph<2,>=1.0
16
+ Requires-Dist: opentelemetry-api>=1.27
17
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27
18
+ Requires-Dist: opentelemetry-sdk>=1.27
19
+ Requires-Dist: pydantic-settings>=2.4
20
+ Requires-Dist: pyjwt[crypto]>=2.9
21
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.30
22
+ Requires-Dist: uvicorn>=0.30
23
+ Provides-Extra: postgres
24
+ Requires-Dist: asyncpg>=0.29; extra == 'postgres'
25
+ Requires-Dist: pgvector>=0.3; extra == 'postgres'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # agentplane-runtime
29
+
30
+ Owns flow definitions and resources; executes flows (LangGraph); serves each
31
+ deployed flow as an **A2A agent** (`/a2a/{name}`, a2a-sdk, A2A v1.0) or an
32
+ **MCP server** (`/mcp/{name}`, FastMCP streamable HTTP); self-registers with
33
+ the agentplane registry.
34
+
35
+ Required configuration (env prefix `AGENTPLANE_RUNTIME_`):
36
+
37
+ | Variable | Purpose |
38
+ |---|---|
39
+ | `PUBLIC_BASE_URL` | externally reachable base (a gateway route) |
40
+ | `REGISTRY_URL` | where to self-register (empty disables registration) |
41
+ | `SECRET_KEY` | Fernet key for resource credentials |
42
+ | `LLM_BASE_URL` | gateway's OpenAI-compatible endpoint (resource default) |
43
+
44
+ Run it:
45
+
46
+ ```
47
+ uvicorn --factory agentplane_runtime.app:create_app --port 8000
48
+ ```
@@ -0,0 +1,21 @@
1
+ # agentplane-runtime
2
+
3
+ Owns flow definitions and resources; executes flows (LangGraph); serves each
4
+ deployed flow as an **A2A agent** (`/a2a/{name}`, a2a-sdk, A2A v1.0) or an
5
+ **MCP server** (`/mcp/{name}`, FastMCP streamable HTTP); self-registers with
6
+ the agentplane registry.
7
+
8
+ Required configuration (env prefix `AGENTPLANE_RUNTIME_`):
9
+
10
+ | Variable | Purpose |
11
+ |---|---|
12
+ | `PUBLIC_BASE_URL` | externally reachable base (a gateway route) |
13
+ | `REGISTRY_URL` | where to self-register (empty disables registration) |
14
+ | `SECRET_KEY` | Fernet key for resource credentials |
15
+ | `LLM_BASE_URL` | gateway's OpenAI-compatible endpoint (resource default) |
16
+
17
+ Run it:
18
+
19
+ ```
20
+ uvicorn --factory agentplane_runtime.app:create_app --port 8000
21
+ ```
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "agentplane-runtime"
3
+ version = "0.0.1"
4
+ description = "agentplane runtime: definitions, resources, flow execution, A2A + MCP serving"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "agentplane-core>=0.0.1,<0.1.0",
10
+ "agentplane-sdk>=0.0.1,<0.1.0",
11
+ "a2a-sdk[http-server]>=1.1,<2",
12
+ "fastapi>=0.115",
13
+ "sqlalchemy[asyncio]>=2.0.30",
14
+ "aiosqlite>=0.20",
15
+ "httpx>=0.27",
16
+ "pydantic-settings>=2.4",
17
+ "pyjwt[crypto]>=2.9",
18
+ "cryptography>=43",
19
+ "langgraph>=1.0,<2",
20
+ # exact pin — fastmcp ships breaking changes in minor releases (CLAUDE.md)
21
+ "fastmcp==3.4.4",
22
+ "opentelemetry-api>=1.27",
23
+ "opentelemetry-sdk>=1.27",
24
+ "opentelemetry-exporter-otlp-proto-http>=1.27",
25
+ "uvicorn>=0.30",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ postgres = ["asyncpg>=0.29", "pgvector>=0.3"]
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/agentplane_runtime"]
37
+
38
+ [tool.uv.sources]
39
+ agentplane-core = { workspace = true }
40
+ agentplane-sdk = { workspace = true }
@@ -0,0 +1,8 @@
1
+ """agentplane-runtime: definitions, resources, flow execution, A2A + MCP serving."""
2
+
3
+ from agentplane_runtime.app import create_app
4
+ from agentplane_runtime.settings import RUNTIME_VERSION, RuntimeSettings
5
+
6
+ __version__ = RUNTIME_VERSION
7
+
8
+ __all__ = ["RUNTIME_VERSION", "RuntimeSettings", "create_app"]
@@ -0,0 +1,232 @@
1
+ """Runtime REST API (SPEC §6.1/§6.3), prefix ``/api/v1``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Annotated, Literal
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
9
+ from fastapi.responses import JSONResponse
10
+
11
+ from agentplane_core import (
12
+ DefinitionInfo,
13
+ DeploymentInfo,
14
+ FlowDefinition,
15
+ JsonObject,
16
+ Resource,
17
+ ValidationResult,
18
+ )
19
+ from agentplane_runtime.auth import Principal
20
+ from agentplane_runtime.definitions import (
21
+ DefinitionConflictError,
22
+ DefinitionInvalidError,
23
+ DefinitionNotFoundError,
24
+ DefinitionService,
25
+ DefinitionStateError,
26
+ )
27
+ from agentplane_runtime.resources import (
28
+ ResourceConflictError,
29
+ ResourceNotFoundError,
30
+ ResourceService,
31
+ ResourceValidationError,
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class RuntimeState:
37
+ definitions: DefinitionService
38
+ resources: ResourceService
39
+
40
+
41
+ def _state(request: Request) -> RuntimeState:
42
+ state: RuntimeState = request.app.state.runtime
43
+ return state
44
+
45
+
46
+ async def _principal(request: Request) -> Principal:
47
+ principal: Principal = await request.app.state.authenticator.authenticate(request)
48
+ return principal
49
+
50
+
51
+ State = Annotated[RuntimeState, Depends(_state)]
52
+ Caller = Annotated[Principal, Depends(_principal)]
53
+
54
+ router = APIRouter(prefix="/api/v1")
55
+ health_router = APIRouter()
56
+
57
+
58
+ def _validation_response(result: ValidationResult) -> JSONResponse:
59
+ return JSONResponse(
60
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
61
+ content=result.model_dump(mode="json"),
62
+ )
63
+
64
+
65
+ @router.post("/definitions/validate", response_model=ValidationResult)
66
+ async def validate_definition(body: JsonObject, state: State, caller: Caller) -> ValidationResult:
67
+ """Always 200 with a ValidationResult, even when invalid (SPEC §6.1)."""
68
+ return await state.definitions.validate(dict(body))
69
+
70
+
71
+ @router.post("/definitions", status_code=status.HTTP_201_CREATED, response_model=DefinitionInfo)
72
+ async def create_definition(
73
+ body: FlowDefinition, state: State, caller: Caller
74
+ ) -> DefinitionInfo | JSONResponse:
75
+ try:
76
+ return await state.definitions.create_draft(body, caller.sub)
77
+ except DefinitionInvalidError as exc:
78
+ return _validation_response(exc.result)
79
+ except DefinitionConflictError as exc:
80
+ raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
81
+
82
+
83
+ @router.put("/definitions/{name}", response_model=DefinitionInfo)
84
+ async def update_definition(
85
+ name: str, body: FlowDefinition, state: State, caller: Caller
86
+ ) -> DefinitionInfo | JSONResponse:
87
+ try:
88
+ return await state.definitions.update_draft(name, body)
89
+ except DefinitionInvalidError as exc:
90
+ return _validation_response(exc.result)
91
+ except DefinitionNotFoundError:
92
+ raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no definition {name!r}") from None
93
+ except DefinitionConflictError as exc:
94
+ raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
95
+
96
+
97
+ @router.post("/definitions/{name}/deploy", response_model=DeploymentInfo)
98
+ async def deploy_definition(
99
+ name: str,
100
+ state: State,
101
+ caller: Caller,
102
+ version: Annotated[int | None, Query(ge=1)] = None,
103
+ ephemeral: Annotated[bool, Query()] = False,
104
+ ) -> DeploymentInfo | JSONResponse:
105
+ try:
106
+ return await state.definitions.deploy(name, version=version, ephemeral=ephemeral)
107
+ except DefinitionInvalidError as exc:
108
+ return _validation_response(exc.result)
109
+ except DefinitionNotFoundError as exc:
110
+ raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from None
111
+
112
+
113
+ @router.post("/definitions/{name}/undeploy", status_code=status.HTTP_204_NO_CONTENT)
114
+ async def undeploy_definition(name: str, state: State, caller: Caller) -> None:
115
+ try:
116
+ await state.definitions.undeploy(name)
117
+ except DefinitionNotFoundError:
118
+ raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no definition {name!r}") from None
119
+ except DefinitionStateError as exc:
120
+ raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
121
+
122
+
123
+ @router.get("/definitions", response_model=list[DefinitionInfo])
124
+ async def list_definitions(
125
+ state: State,
126
+ caller: Caller,
127
+ status_filter: Annotated[
128
+ Literal["draft", "deployed", "undeployed"] | None, Query(alias="status")
129
+ ] = None,
130
+ ) -> list[DefinitionInfo]:
131
+ return await state.definitions.list(status_filter)
132
+
133
+
134
+ @router.get("/definitions/{name}", response_model=DefinitionInfo)
135
+ async def get_definition(
136
+ name: str,
137
+ state: State,
138
+ caller: Caller,
139
+ include: Annotated[Literal["definition"] | None, Query()] = None,
140
+ ) -> DefinitionInfo:
141
+ try:
142
+ return await state.definitions.info(name, include_definition=include == "definition")
143
+ except DefinitionNotFoundError:
144
+ raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no definition {name!r}") from None
145
+
146
+
147
+ @router.get("/definitions/{name}/export")
148
+ async def export_definition(
149
+ name: str,
150
+ state: State,
151
+ caller: Caller,
152
+ version: Annotated[int | None, Query(ge=1)] = None,
153
+ ) -> JsonObject:
154
+ try:
155
+ defn = await state.definitions.export(name, version)
156
+ except DefinitionNotFoundError as exc:
157
+ raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from None
158
+ return defn.canonical_dict()
159
+
160
+
161
+ @router.delete("/definitions/{name}", status_code=status.HTTP_204_NO_CONTENT)
162
+ async def delete_definition(name: str, state: State, caller: Caller) -> None:
163
+ try:
164
+ await state.definitions.delete(name)
165
+ except DefinitionNotFoundError:
166
+ raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no definition {name!r}") from None
167
+ except DefinitionStateError as exc:
168
+ raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
169
+
170
+
171
+ @router.post("/resources", status_code=status.HTTP_201_CREATED, response_model=Resource)
172
+ async def create_resource(body: Resource, state: State, caller: Caller) -> Resource:
173
+ try:
174
+ return await state.resources.create(body, caller.sub)
175
+ except ResourceConflictError as exc:
176
+ raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
177
+ except ResourceValidationError as exc:
178
+ raise HTTPException(
179
+ status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.result.model_dump(mode="json")
180
+ ) from None
181
+
182
+
183
+ @router.get("/resources", response_model=list[Resource])
184
+ async def list_resources(
185
+ state: State,
186
+ caller: Caller,
187
+ kind: Annotated[str | None, Query()] = None,
188
+ ) -> list[Resource]:
189
+ return await state.resources.list(kind)
190
+
191
+
192
+ @router.get("/resources/{name}", response_model=Resource)
193
+ async def get_resource(name: str, state: State, caller: Caller) -> Resource:
194
+ try:
195
+ return await state.resources.get(name)
196
+ except ResourceNotFoundError:
197
+ raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no resource {name!r}") from None
198
+
199
+
200
+ @router.put("/resources/{name}", response_model=Resource)
201
+ async def update_resource(name: str, body: Resource, state: State, caller: Caller) -> Resource:
202
+ try:
203
+ return await state.resources.update(name, body, caller.sub)
204
+ except ResourceNotFoundError:
205
+ raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no resource {name!r}") from None
206
+ except ResourceValidationError as exc:
207
+ raise HTTPException(
208
+ status.HTTP_422_UNPROCESSABLE_ENTITY, detail=exc.result.model_dump(mode="json")
209
+ ) from None
210
+
211
+
212
+ @router.delete("/resources/{name}", status_code=status.HTTP_204_NO_CONTENT)
213
+ async def delete_resource(name: str, state: State, caller: Caller) -> None:
214
+ try:
215
+ await state.resources.delete(name)
216
+ except ResourceNotFoundError:
217
+ raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"no resource {name!r}") from None
218
+ except ResourceConflictError as exc:
219
+ raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
220
+
221
+
222
+ @health_router.get("/healthz")
223
+ async def healthz() -> dict[str, str]:
224
+ return {"status": "ok"}
225
+
226
+
227
+ @health_router.get("/readyz")
228
+ async def readyz() -> dict[str, str]:
229
+ return {"status": "ready"}
230
+
231
+
232
+ __all__ = ["RuntimeState", "health_router", "router"]
@@ -0,0 +1,85 @@
1
+ """Runtime app factory: API + dynamic /a2a and /mcp endpoint serving."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ from collections.abc import AsyncIterator
9
+ from contextlib import asynccontextmanager
10
+
11
+ from fastapi import FastAPI
12
+
13
+ from agentplane_runtime.api import RuntimeState, health_router, router
14
+ from agentplane_runtime.auth import Authenticator
15
+ from agentplane_runtime.db import Database
16
+ from agentplane_runtime.definitions import DefinitionService
17
+ from agentplane_runtime.registration import RegistryRegistrar
18
+ from agentplane_runtime.resources import ResourceService
19
+ from agentplane_runtime.secrets import FernetSecretsProvider
20
+ from agentplane_runtime.serving import EndpointManager
21
+ from agentplane_runtime.settings import RUNTIME_VERSION, RuntimeSettings
22
+ from agentplane_runtime.tracing import setup_tracing
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _REAP_INTERVAL_S = 60.0
27
+
28
+
29
+ def create_app(settings: RuntimeSettings | None = None) -> FastAPI:
30
+ cfg = settings or RuntimeSettings()
31
+ if not cfg.public_base_url:
32
+ raise RuntimeError("AGENTPLANE_RUNTIME_PUBLIC_BASE_URL is required")
33
+ if not cfg.secret_key:
34
+ raise RuntimeError("AGENTPLANE_RUNTIME_SECRET_KEY is required")
35
+
36
+ db = Database(cfg.db_url)
37
+ secrets = FernetSecretsProvider(db, cfg.secret_key)
38
+ resources = ResourceService(db, secrets)
39
+ endpoints = EndpointManager(resources, cfg)
40
+ registrar = RegistryRegistrar(cfg)
41
+ definitions = DefinitionService(db, resources, endpoints, registrar)
42
+
43
+ @asynccontextmanager
44
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
45
+ await db.create_all()
46
+ try:
47
+ await definitions.restore_deployed_endpoints()
48
+ except Exception:
49
+ logger.exception("failed to restore deployed endpoints")
50
+
51
+ async def reaper() -> None:
52
+ while True:
53
+ await asyncio.sleep(_REAP_INTERVAL_S)
54
+ await endpoints.reap_expired()
55
+
56
+ reap_task = asyncio.create_task(reaper(), name="ephemeral-reaper")
57
+ try:
58
+ yield
59
+ finally:
60
+ reap_task.cancel()
61
+ with contextlib.suppress(asyncio.CancelledError):
62
+ await reap_task
63
+ await registrar.shutdown()
64
+ await endpoints.stop_all()
65
+ await db.dispose()
66
+
67
+ app = FastAPI(title="agentplane-runtime", version=RUNTIME_VERSION, lifespan=lifespan)
68
+ app.state.runtime = RuntimeState(definitions=definitions, resources=resources)
69
+ app.state.authenticator = Authenticator(cfg)
70
+ app.state.endpoints = endpoints
71
+ app.include_router(router)
72
+ app.include_router(health_router)
73
+ app.mount("/a2a", endpoints.a2a)
74
+ app.mount("/mcp", endpoints.mcp)
75
+ setup_tracing(app, service_name="agentplane-runtime")
76
+ return app
77
+
78
+
79
+ def main() -> None: # pragma: no cover - process entrypoint
80
+ import uvicorn # noqa: PLC0415 - only needed for the entrypoint
81
+
82
+ uvicorn.run(create_app(), host="0.0.0.0", port=8000)
83
+
84
+
85
+ __all__ = ["create_app", "main"]
@@ -0,0 +1,137 @@
1
+ """Optional OIDC auth for the runtime API (SPEC §7.1), same model as the registry.
2
+
3
+ Deliberately a sibling implementation — services never import each other
4
+ (architecture invariant 3), and core stays free of HTTP/JWT dependencies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from dataclasses import dataclass, field
11
+
12
+ import httpx
13
+ import jwt
14
+ from fastapi import HTTPException, Request, status
15
+ from jwt.types import Options
16
+
17
+ from agentplane_runtime.settings import RuntimeSettings
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Principal:
22
+ sub: str
23
+ roles: frozenset[str] = field(default_factory=frozenset)
24
+ is_admin: bool = False
25
+
26
+
27
+ ANONYMOUS = Principal(sub="anonymous", roles=frozenset(), is_admin=False)
28
+
29
+
30
+ def _claim_path(claims: dict[str, object], path: str) -> object:
31
+ value: object = claims
32
+ for part in path.split("."):
33
+ if not isinstance(value, dict):
34
+ return None
35
+ value = value.get(part)
36
+ return value
37
+
38
+
39
+ class OidcValidator:
40
+ def __init__(
41
+ self,
42
+ issuer: str,
43
+ audience: str,
44
+ roles_claim: str,
45
+ admin_role: str,
46
+ *,
47
+ jwks_ttl_s: float = 300.0,
48
+ ) -> None:
49
+ self._issuer = issuer.rstrip("/")
50
+ self._audience = audience
51
+ self._roles_claim = roles_claim
52
+ self._admin_role = admin_role
53
+ self._jwks_ttl_s = jwks_ttl_s
54
+ self._jwks: dict[str, jwt.PyJWK] = {}
55
+ self._jwks_fetched_at = 0.0
56
+
57
+ async def _refresh_jwks(self) -> None:
58
+ async with httpx.AsyncClient(timeout=10.0) as client:
59
+ discovery = await client.get(f"{self._issuer}/.well-known/openid-configuration")
60
+ discovery.raise_for_status()
61
+ jwks_uri = discovery.json().get("jwks_uri")
62
+ if not isinstance(jwks_uri, str):
63
+ raise RuntimeError("issuer discovery returned no jwks_uri")
64
+ jwks_response = await client.get(jwks_uri)
65
+ jwks_response.raise_for_status()
66
+ keys = jwt.PyJWKSet.from_dict(jwks_response.json()).keys
67
+ self._jwks = {key.key_id: key for key in keys if key.key_id}
68
+ self._jwks_fetched_at = time.monotonic()
69
+
70
+ async def _key_for(self, kid: str) -> jwt.PyJWK:
71
+ stale = time.monotonic() - self._jwks_fetched_at > self._jwks_ttl_s
72
+ if kid not in self._jwks or stale:
73
+ await self._refresh_jwks()
74
+ key = self._jwks.get(kid)
75
+ if key is None:
76
+ raise jwt.InvalidTokenError(f"unknown key id {kid!r}")
77
+ return key
78
+
79
+ async def validate(self, token: str) -> Principal:
80
+ header = jwt.get_unverified_header(token)
81
+ kid = header.get("kid")
82
+ if not isinstance(kid, str):
83
+ raise jwt.InvalidTokenError("token has no kid header")
84
+ key = await self._key_for(kid)
85
+ options: Options = {"verify_aud": bool(self._audience)}
86
+ claims = jwt.decode(
87
+ token,
88
+ key=key.key,
89
+ algorithms=["RS256", "ES256"],
90
+ audience=self._audience or None,
91
+ issuer=self._issuer,
92
+ options=options,
93
+ )
94
+ roles_value = _claim_path(claims, self._roles_claim)
95
+ roles = (
96
+ frozenset(str(role) for role in roles_value)
97
+ if isinstance(roles_value, list)
98
+ else frozenset()
99
+ )
100
+ sub = claims.get("sub")
101
+ if not isinstance(sub, str) or not sub:
102
+ raise jwt.InvalidTokenError("token has no sub")
103
+ return Principal(sub=sub, roles=roles, is_admin=self._admin_role in roles)
104
+
105
+
106
+ class Authenticator:
107
+ def __init__(self, settings: RuntimeSettings) -> None:
108
+ self.mode = settings.auth_mode
109
+ self._validator: OidcValidator | None = None
110
+ if self.mode == "oidc":
111
+ if not settings.oidc_issuer:
112
+ raise RuntimeError("AUTH_MODE=oidc requires AGENTPLANE_RUNTIME_OIDC_ISSUER")
113
+ self._validator = OidcValidator(
114
+ settings.oidc_issuer,
115
+ settings.oidc_audience,
116
+ settings.roles_claim,
117
+ settings.admin_role,
118
+ )
119
+
120
+ async def authenticate(self, request: Request) -> Principal:
121
+ if self.mode == "none" or self._validator is None:
122
+ return ANONYMOUS
123
+ header = request.headers.get("Authorization", "")
124
+ scheme, _, token = header.partition(" ")
125
+ if scheme.lower() != "bearer" or not token:
126
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="missing bearer token")
127
+ try:
128
+ return await self._validator.validate(token)
129
+ except jwt.InvalidTokenError as exc:
130
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
131
+ except httpx.HTTPError as exc:
132
+ raise HTTPException(
133
+ status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"issuer unreachable: {exc}"
134
+ ) from exc
135
+
136
+
137
+ __all__ = ["ANONYMOUS", "Authenticator", "OidcValidator", "Principal"]