Skip to content

Commit 841bcda

Browse files
feat(runtime): add credential resolver and request context for SGP delegation
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent bbfb22e commit 841bcda

9 files changed

Lines changed: 511 additions & 38 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from agentex.lib.runtime.models import Credentials, CredentialScheme
2+
from agentex.lib.runtime.context import (
3+
RequestContext,
4+
current_request,
5+
request_context_scope,
6+
get_credential_resolver,
7+
set_credential_resolver,
8+
run_with_request_context,
9+
wrap_async_generator_with_request_context,
10+
)
11+
from agentex.lib.runtime.resolver import (
12+
SGP_TARGET,
13+
ENV_SGP_API_KEY,
14+
HEADER_ACTING_AS_AGENT,
15+
HEADER_ACTING_USER_API_KEY,
16+
CredentialResolver,
17+
PassthroughResolver,
18+
)
19+
20+
__all__ = [
21+
"CredentialResolver",
22+
"CredentialScheme",
23+
"Credentials",
24+
"ENV_SGP_API_KEY",
25+
"HEADER_ACTING_AS_AGENT",
26+
"HEADER_ACTING_USER_API_KEY",
27+
"PassthroughResolver",
28+
"RequestContext",
29+
"SGP_TARGET",
30+
"current_request",
31+
"get_credential_resolver",
32+
"request_context_scope",
33+
"run_with_request_context",
34+
"set_credential_resolver",
35+
"wrap_async_generator_with_request_context",
36+
]

src/agentex/lib/runtime/context.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from __future__ import annotations
2+
3+
import contextvars
4+
from typing import TypeVar
5+
from contextlib import asynccontextmanager
6+
from collections.abc import Callable, Awaitable, AsyncIterator, AsyncGenerator
7+
8+
from agentex.lib.runtime.models import Credentials
9+
from agentex.lib.runtime.resolver import CredentialResolver, PassthroughResolver
10+
11+
T = TypeVar("T")
12+
13+
_ctx_var_request_context: contextvars.ContextVar[RequestContext | None] = contextvars.ContextVar(
14+
"agentex_request_context", default=None
15+
)
16+
17+
_default_resolver: CredentialResolver = PassthroughResolver()
18+
19+
20+
def set_credential_resolver(resolver: CredentialResolver) -> None:
21+
"""Configure the credential resolver used by runtime request context."""
22+
global _default_resolver
23+
_default_resolver = resolver
24+
25+
26+
def get_credential_resolver() -> CredentialResolver:
27+
return _default_resolver
28+
29+
30+
class RequestContext:
31+
"""Per-request runtime context populated by FastACP on each inbound RPC."""
32+
33+
def __init__(
34+
self,
35+
*,
36+
headers: dict[str, str],
37+
agent_id: str,
38+
resolver: CredentialResolver,
39+
) -> None:
40+
self._headers = headers
41+
self._agent_id = agent_id
42+
self._resolver = resolver
43+
44+
@classmethod
45+
def from_headers(
46+
cls,
47+
headers: dict[str, str],
48+
agent_id: str,
49+
resolver: CredentialResolver | None = None,
50+
) -> RequestContext:
51+
return cls(
52+
headers=headers,
53+
agent_id=agent_id,
54+
resolver=resolver or get_credential_resolver(),
55+
)
56+
57+
@property
58+
def headers(self) -> dict[str, str]:
59+
return self._headers
60+
61+
@property
62+
def agent_id(self) -> str:
63+
return self._agent_id
64+
65+
async def get_credentials_for(self, target: str) -> Credentials:
66+
return await self._resolver.resolve(self._headers, self._agent_id, target)
67+
68+
async def get_token(self, target: str = "sgp") -> str:
69+
"""Return the raw credential value for a target (convenience wrapper)."""
70+
credentials = await self.get_credentials_for(target)
71+
return credentials.value
72+
73+
74+
def current_request() -> RequestContext:
75+
"""Return the active request context for the current RPC handler."""
76+
context = _ctx_var_request_context.get()
77+
if context is None:
78+
raise RuntimeError(
79+
"No active Agentex request context. Call current_request() only "
80+
"from code running inside an agent RPC handler."
81+
)
82+
return context
83+
84+
85+
@asynccontextmanager
86+
async def request_context_scope(
87+
headers: dict[str, str],
88+
agent_id: str,
89+
resolver: CredentialResolver | None = None,
90+
) -> AsyncGenerator[RequestContext, None]:
91+
context = RequestContext.from_headers(headers, agent_id, resolver)
92+
token = _ctx_var_request_context.set(context)
93+
try:
94+
yield context
95+
finally:
96+
_ctx_var_request_context.reset(token)
97+
98+
99+
async def run_with_request_context(
100+
headers: dict[str, str],
101+
agent_id: str,
102+
fn: Callable[[], Awaitable[T]],
103+
*,
104+
resolver: CredentialResolver | None = None,
105+
) -> T:
106+
async with request_context_scope(headers, agent_id, resolver):
107+
return await fn()
108+
109+
110+
async def wrap_async_generator_with_request_context(
111+
async_gen: AsyncIterator[T],
112+
headers: dict[str, str],
113+
agent_id: str,
114+
*,
115+
resolver: CredentialResolver | None = None,
116+
) -> AsyncGenerator[T, None]:
117+
"""Keep request context active while a streaming handler yields chunks."""
118+
async with request_context_scope(headers, agent_id, resolver):
119+
async for item in async_gen:
120+
yield item

src/agentex/lib/runtime/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from __future__ import annotations
2+
3+
from typing import Literal
4+
5+
from pydantic import Field, BaseModel
6+
7+
CredentialScheme = Literal["api_key", "bearer"]
8+
9+
10+
class Credentials(BaseModel):
11+
"""Resolved outbound credentials for a downstream target."""
12+
13+
scheme: CredentialScheme = Field(
14+
...,
15+
description="How to attach the credential to an HTTP request",
16+
)
17+
value: str = Field(..., description="Secret credential value")
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import Protocol, runtime_checkable
5+
6+
from agentex.lib.runtime.models import Credentials
7+
8+
HEADER_ACTING_USER_API_KEY = "x-acting-user-api-key"
9+
HEADER_ACTING_AS_AGENT = "x-acting-as-agent"
10+
SGP_TARGET = "sgp"
11+
ENV_SGP_API_KEY = "SGP_API_KEY"
12+
13+
14+
def _normalize_headers(headers: dict[str, str]) -> dict[str, str]:
15+
return {key.lower(): value for key, value in headers.items()}
16+
17+
18+
@runtime_checkable
19+
class CredentialResolver(Protocol):
20+
"""Plugin interface for resolving outbound credentials by target name."""
21+
22+
async def resolve(
23+
self,
24+
headers: dict[str, str],
25+
agent_id: str,
26+
target: str,
27+
) -> Credentials:
28+
"""Return credentials for the given target using inbound request context."""
29+
...
30+
31+
32+
class PassthroughResolver:
33+
"""Default resolver: forwarded user API key, then legacy SGP_API_KEY fallback."""
34+
35+
async def resolve(
36+
self,
37+
headers: dict[str, str],
38+
agent_id: str,
39+
target: str,
40+
) -> Credentials:
41+
del agent_id # reserved for future per-agent resolution (OBO, vault lookups)
42+
43+
normalized = _normalize_headers(headers)
44+
api_key = normalized.get(HEADER_ACTING_USER_API_KEY) or os.environ.get(ENV_SGP_API_KEY)
45+
if not api_key:
46+
raise RuntimeError(
47+
"No credential available for target "
48+
f"'{target}': expected inbound header "
49+
f"'{HEADER_ACTING_USER_API_KEY}' or environment variable "
50+
f"'{ENV_SGP_API_KEY}'."
51+
)
52+
53+
if target == SGP_TARGET:
54+
return Credentials(scheme="api_key", value=api_key)
55+
56+
# v1 passthrough uses the same user API key shape for all targets.
57+
return Credentials(scheme="bearer", value=api_key)

0 commit comments

Comments
 (0)