agentic-runner 3.7.0__tar.gz → 3.8.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.
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/PKG-INFO +1 -1
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/__init__.py +1 -1
- agentic_runner-3.8.0/src/agentic_runner/integrations/oauth/__init__.py +68 -0
- agentic_runner-3.8.0/src/agentic_runner/integrations/oauth/fake_provider.py +134 -0
- agentic_runner-3.8.0/src/agentic_runner/oauth_connectors.py +592 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/sealed_box.py +95 -1
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/service.py +19 -1
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/.gitignore +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/LICENSE +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/README.md +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/pyproject.toml +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/activities.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/auth_mode.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/build.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/cli_floor.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/config.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/device_login_activities.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/harness_self_test.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/private_state.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/registration.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/__init__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/__main__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/control_plane.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/plugin.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/test_conformance.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/workflow.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/usage_windows.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/acp_runtime.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/settings.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/skills.py +0 -0
- {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workstation.py +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The OAuth provider port: one form POST to a token or revocation endpoint (issue 30).
|
|
2
|
+
|
|
3
|
+
RFC 6749 §4.1.3 / §6 and RFC 7009 are each a form POST answered with JSON, so the port is
|
|
4
|
+
exactly that and the protocol lives in :mod:`agentic_runner.oauth_connectors`, where a
|
|
5
|
+
fake provider exercises all of it. ``HttpOAuthProvider`` is the Runner's own outbound HTTPS
|
|
6
|
+
-- the exchange never touches the control plane.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Final, Protocol
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"HttpOAuthProvider",
|
|
19
|
+
"OAuthProvider",
|
|
20
|
+
"OAuthProviderError",
|
|
21
|
+
"ProviderResponse",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# One beat's budget: the exchange and a refresh run on the heartbeat, so a provider that
|
|
25
|
+
# hangs must cost a failed step and not a stale link.
|
|
26
|
+
_TIMEOUT: Final[float] = 10.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OAuthProviderError(RuntimeError):
|
|
30
|
+
"""The provider could not be reached or did not answer HTTP. Never carries a body."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ProviderResponse:
|
|
35
|
+
status: int
|
|
36
|
+
# repr=False: a token response is the one body that must never reach a log line.
|
|
37
|
+
body: Mapping[str, object] = field(default_factory=dict, repr=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OAuthProvider(Protocol):
|
|
41
|
+
async def post_form(self, url: str, form: Mapping[str, str]) -> ProviderResponse: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HttpOAuthProvider:
|
|
45
|
+
"""The real provider: HTTPS only, no redirects, JSON asked for.
|
|
46
|
+
|
|
47
|
+
Redirects are not followed because a 30x on a token endpoint would re-send the code
|
|
48
|
+
and the verifier to wherever it points. ``Accept: application/json`` because GitHub,
|
|
49
|
+
among others, answers form-encoded unless asked.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
async def post_form(self, url: str, form: Mapping[str, str]) -> ProviderResponse:
|
|
53
|
+
try:
|
|
54
|
+
async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=False) as client:
|
|
55
|
+
response = await client.post(
|
|
56
|
+
url, data=dict(form), headers={"Accept": "application/json"}
|
|
57
|
+
)
|
|
58
|
+
except httpx.HTTPError as error:
|
|
59
|
+
# The type only: an httpx error's text can carry the request URL, and a
|
|
60
|
+
# refresh's form is in no URL, but nothing here needs more than the class.
|
|
61
|
+
raise OAuthProviderError(type(error).__name__) from None
|
|
62
|
+
try:
|
|
63
|
+
body = response.json()
|
|
64
|
+
except ValueError:
|
|
65
|
+
body = {}
|
|
66
|
+
return ProviderResponse(
|
|
67
|
+
status=response.status_code, body=body if isinstance(body, dict) else {}
|
|
68
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""A fake OAuth provider that enforces what a real one does (issue 30's Runner tests).
|
|
2
|
+
|
|
3
|
+
Strict on purpose: a code is single-use and bound to its client, redirect and S256
|
|
4
|
+
challenge; a refresh token rotates and the old one stops working; revocation ends both.
|
|
5
|
+
A Runner that sent the wrong verifier, replayed a code or kept an old refresh token fails
|
|
6
|
+
here the way it would fail against a provider.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import secrets
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from urllib.parse import parse_qs, urlsplit
|
|
17
|
+
|
|
18
|
+
from agentic_runner.integrations.oauth import ProviderResponse
|
|
19
|
+
|
|
20
|
+
__all__ = ["FakeOAuthProvider"]
|
|
21
|
+
|
|
22
|
+
AUTHORIZE = "https://provider.test/authorize"
|
|
23
|
+
TOKEN = "https://provider.test/token"
|
|
24
|
+
REVOKE = "https://provider.test/revoke"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class _Grant:
|
|
29
|
+
client_id: str
|
|
30
|
+
redirect_uri: str
|
|
31
|
+
challenge: str
|
|
32
|
+
scope: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class FakeOAuthProvider:
|
|
37
|
+
client_id: str = "client-123"
|
|
38
|
+
client_secret: str | None = None
|
|
39
|
+
expires_in: int | None = 3600
|
|
40
|
+
authorize_endpoint: str = AUTHORIZE
|
|
41
|
+
token_endpoint: str = TOKEN
|
|
42
|
+
revocation_endpoint: str = REVOKE
|
|
43
|
+
requests: list[tuple[str, dict[str, str]]] = field(default_factory=list)
|
|
44
|
+
issued: list[str] = field(default_factory=list)
|
|
45
|
+
revoked: set[str] = field(default_factory=set)
|
|
46
|
+
_codes: dict[str, _Grant] = field(default_factory=dict)
|
|
47
|
+
# token -> the grant it belongs to; a refresh token also keeps the grant's scope.
|
|
48
|
+
_access: dict[str, str] = field(default_factory=dict)
|
|
49
|
+
_refresh: dict[str, tuple[str, str]] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
def consent(self, authorize_url: str) -> tuple[str, str]:
|
|
52
|
+
"""The person approves: the ``(code, state)`` the callback URL would carry."""
|
|
53
|
+
|
|
54
|
+
query = {key: values[0] for key, values in parse_qs(urlsplit(authorize_url).query).items()}
|
|
55
|
+
assert query["response_type"] == "code"
|
|
56
|
+
assert query["code_challenge_method"] == "S256"
|
|
57
|
+
code = f"code-{secrets.token_hex(8)}"
|
|
58
|
+
self._codes[code] = _Grant(
|
|
59
|
+
client_id=query["client_id"],
|
|
60
|
+
redirect_uri=query["redirect_uri"],
|
|
61
|
+
challenge=query["code_challenge"],
|
|
62
|
+
scope=query.get("scope", ""),
|
|
63
|
+
)
|
|
64
|
+
return code, query["state"]
|
|
65
|
+
|
|
66
|
+
def access_valid(self, token: str) -> bool:
|
|
67
|
+
return token in self._access and token not in self.revoked
|
|
68
|
+
|
|
69
|
+
async def post_form(self, url: str, form: Mapping[str, str]) -> ProviderResponse:
|
|
70
|
+
self.requests.append((url, dict(form)))
|
|
71
|
+
if url == self.revocation_endpoint:
|
|
72
|
+
return self._revoke(form)
|
|
73
|
+
if url != self.token_endpoint:
|
|
74
|
+
return ProviderResponse(404)
|
|
75
|
+
if not self._client_ok(form):
|
|
76
|
+
return _error(401, "invalid_client")
|
|
77
|
+
if form.get("grant_type") == "authorization_code":
|
|
78
|
+
return self._exchange(form)
|
|
79
|
+
if form.get("grant_type") == "refresh_token":
|
|
80
|
+
return self._rotate(form)
|
|
81
|
+
return _error(400, "unsupported_grant_type")
|
|
82
|
+
|
|
83
|
+
def _client_ok(self, form: Mapping[str, str]) -> bool:
|
|
84
|
+
if form.get("client_id") != self.client_id:
|
|
85
|
+
return False
|
|
86
|
+
return self.client_secret is None or form.get("client_secret") == self.client_secret
|
|
87
|
+
|
|
88
|
+
def _exchange(self, form: Mapping[str, str]) -> ProviderResponse:
|
|
89
|
+
grant = self._codes.pop(form.get("code", ""), None)
|
|
90
|
+
if grant is None or grant.redirect_uri != form.get("redirect_uri"):
|
|
91
|
+
return _error(400, "invalid_grant")
|
|
92
|
+
verifier = form.get("code_verifier", "")
|
|
93
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
94
|
+
if base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") != grant.challenge:
|
|
95
|
+
return _error(400, "invalid_grant")
|
|
96
|
+
return self._tokens(f"grant-{secrets.token_hex(8)}", grant.scope)
|
|
97
|
+
|
|
98
|
+
def _rotate(self, form: Mapping[str, str]) -> ProviderResponse:
|
|
99
|
+
held = self._refresh.pop(form.get("refresh_token", ""), None)
|
|
100
|
+
if held is None:
|
|
101
|
+
return _error(400, "invalid_grant")
|
|
102
|
+
return self._tokens(*held)
|
|
103
|
+
|
|
104
|
+
def _tokens(self, grant_id: str, scope: str) -> ProviderResponse:
|
|
105
|
+
access = f"at-{secrets.token_hex(16)}"
|
|
106
|
+
refresh = f"rt-{secrets.token_hex(16)}"
|
|
107
|
+
self._access[access] = grant_id
|
|
108
|
+
self._refresh[refresh] = (grant_id, scope)
|
|
109
|
+
self.issued += [access, refresh]
|
|
110
|
+
body: dict[str, object] = {
|
|
111
|
+
"access_token": access,
|
|
112
|
+
"refresh_token": refresh,
|
|
113
|
+
"token_type": "Bearer",
|
|
114
|
+
"scope": scope,
|
|
115
|
+
}
|
|
116
|
+
if self.expires_in is not None:
|
|
117
|
+
body["expires_in"] = self.expires_in
|
|
118
|
+
return ProviderResponse(200, body)
|
|
119
|
+
|
|
120
|
+
def _revoke(self, form: Mapping[str, str]) -> ProviderResponse:
|
|
121
|
+
if not self._client_ok(form):
|
|
122
|
+
return _error(401, "invalid_client")
|
|
123
|
+
token = form.get("token", "")
|
|
124
|
+
self.revoked.add(token)
|
|
125
|
+
# RFC 7009 §2.1: revoking a refresh token may revoke the grant's access tokens;
|
|
126
|
+
# this provider does, so the test can see the access token stop working.
|
|
127
|
+
held = self._refresh.pop(token, None)
|
|
128
|
+
if held is not None:
|
|
129
|
+
self.revoked.update(t for t, grant_id in self._access.items() if grant_id == held[0])
|
|
130
|
+
return ProviderResponse(200)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _error(status: int, code: str) -> ProviderResponse:
|
|
134
|
+
return ProviderResponse(status, {"error": code, "error_description": "fake provider"})
|
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""OAuth Connectors, the Runner's half: start, exchange, refresh, revoke (console-v2 issue 30).
|
|
2
|
+
|
|
3
|
+
ADR-0017 §3 and slice 24's flow: the platform never holds a readable OAuth token, so every
|
|
4
|
+
step that touches the verifier, the code or a token runs here.
|
|
5
|
+
|
|
6
|
+
* **Start** (step 2). A PKCE verifier and its S256 challenge (RFC 7636) and a ``state``
|
|
7
|
+
nonce, kept in process memory only; the authorise URL goes out on the next beat.
|
|
8
|
+
* **Exchange** (step 4). The code arrives sealed to this installation's Recipient Key. It is
|
|
9
|
+
spent only if ``state`` matches and the authorisation is still waiting, and spending it
|
|
10
|
+
ends the authorisation, so a replay finds nothing to spend. The tokens are sealed to the
|
|
11
|
+
Recipient Key as the slot's next version and only that ciphertext leaves.
|
|
12
|
+
* **Refresh** (step 5), when the access token is within :data:`REFRESH_MARGIN` of expiry or
|
|
13
|
+
when a caller saw a 401: re-sealed and uploaded the same way.
|
|
14
|
+
* **Revoke** (step 6). On Disconnect the plaintext goes, and the provider's revocation
|
|
15
|
+
endpoint is called where the template has one (RFC 7009).
|
|
16
|
+
|
|
17
|
+
**Backed by the sealed slot, not by a file.** The token set is the slot's plaintext, so the
|
|
18
|
+
ack hands it back after a restart and to every other Runner of the installation, which open
|
|
19
|
+
it with the same key. The Runner writes no token to disk and does not write the host store:
|
|
20
|
+
``put`` there stays the host operator's act (``host_store``).
|
|
21
|
+
|
|
22
|
+
**Only a token set this installation sealed is one.** A funder can seal any value to the
|
|
23
|
+
same slot through Credential Delivery, and it opens exactly as the Runner's own upload does,
|
|
24
|
+
so the Runner adopts a slot as a token set only when its tag, keyed off the Recipient
|
|
25
|
+
private key, verifies (``SealedCredentialStream.authored``). Anything else is served as the
|
|
26
|
+
funder delivered it and is never refreshed or revoked -- a forged token set would otherwise
|
|
27
|
+
name the endpoint the Runner posts the host's client secret to.
|
|
28
|
+
|
|
29
|
+
**What a Tool Server sees** is the access token, under the slot's name, through the same
|
|
30
|
+
resolver as any delivered value. The token set's own shape -- refresh token, endpoint,
|
|
31
|
+
client -- is Runner-private and is in no contracts model.
|
|
32
|
+
|
|
33
|
+
**Two Runners, one slot.** Both may refresh near expiry. The upload is the slot's next
|
|
34
|
+
version, and the first one the platform stores wins; the other Runner adopts it from the
|
|
35
|
+
next ack (:meth:`OAuthConnectors.resolved`). A provider that revokes a whole grant on refresh
|
|
36
|
+
token reuse can still end the loser's grant -- the slot then reports ``refresh_failed`` and
|
|
37
|
+
the Connector card asks for a reconnect.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import asyncio
|
|
43
|
+
import base64
|
|
44
|
+
import hashlib
|
|
45
|
+
import hmac
|
|
46
|
+
import json
|
|
47
|
+
import logging
|
|
48
|
+
import re
|
|
49
|
+
import secrets
|
|
50
|
+
from collections.abc import Callable, Mapping
|
|
51
|
+
from dataclasses import dataclass, field, replace
|
|
52
|
+
from datetime import UTC, datetime, timedelta
|
|
53
|
+
from typing import Annotated, Final
|
|
54
|
+
from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
55
|
+
from uuid import UUID
|
|
56
|
+
|
|
57
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, StringConstraints, ValidationError
|
|
58
|
+
|
|
59
|
+
from agentic_runner.credentials import CredentialResolver, UnresolvableCredentialReferenceError
|
|
60
|
+
from agentic_runner.integrations.oauth import OAuthProvider, OAuthProviderError, ProviderResponse
|
|
61
|
+
from agentic_runner.sealed_box import SealedCredentialStream
|
|
62
|
+
from agentic_runner_contracts.oauth_connector import (
|
|
63
|
+
OAUTH_WINDOW,
|
|
64
|
+
HttpsUrl,
|
|
65
|
+
OAuthAuthorizeUrl,
|
|
66
|
+
OAuthClient,
|
|
67
|
+
OAuthDisconnect,
|
|
68
|
+
OAuthOutcome,
|
|
69
|
+
OAuthOutcomeKind,
|
|
70
|
+
OAuthStart,
|
|
71
|
+
SealedOAuthCode,
|
|
72
|
+
)
|
|
73
|
+
from agentic_runner_contracts.sealed_credential import (
|
|
74
|
+
CIPHERTEXT_MAX_CHARS,
|
|
75
|
+
SealedCredential,
|
|
76
|
+
SlotName,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
__all__ = ["REFRESH_MARGIN", "TOKEN_SET_PREFIX", "OAuthConnectors", "TokenSet"]
|
|
80
|
+
|
|
81
|
+
_logger = logging.getLogger(__name__)
|
|
82
|
+
|
|
83
|
+
# Marks an authored slot value (``SealedCredentialStream.authored``) as a token set.
|
|
84
|
+
# Versioned like a seal domain, so a later shape is a new prefix. Not what tells a token
|
|
85
|
+
# set from a funder's key -- anyone can write a prefix; only the authored tag says that.
|
|
86
|
+
TOKEN_SET_PREFIX: Final[str] = "agentic-os/oauth-token/v1:"
|
|
87
|
+
|
|
88
|
+
# Two beats and change: a token refreshed this early is never spent expired by a Tool
|
|
89
|
+
# Server between beats, and a provider's clock skew of a minute or two does not matter.
|
|
90
|
+
REFRESH_MARGIN: Final[timedelta] = timedelta(minutes=5)
|
|
91
|
+
|
|
92
|
+
# Bounds memory against a control plane that keeps starting authorisations nobody finishes.
|
|
93
|
+
_PENDING_MAX: Final[int] = 64
|
|
94
|
+
|
|
95
|
+
_PROVIDER_ERROR = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
|
|
96
|
+
|
|
97
|
+
SlotKey = tuple[str, str]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _TokenSetBody(BaseModel):
|
|
101
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
102
|
+
|
|
103
|
+
access_token: Annotated[str, StringConstraints(min_length=1)]
|
|
104
|
+
refresh_token: Annotated[str, StringConstraints(min_length=1)] | None
|
|
105
|
+
expires_at: AwareDatetime | None
|
|
106
|
+
token_endpoint: HttpsUrl
|
|
107
|
+
client_id: Annotated[str, StringConstraints(min_length=1, max_length=512)]
|
|
108
|
+
client_secret_slot: SlotName | None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class TokenSet:
|
|
113
|
+
"""One slot's tokens and what refreshing them needs. Process memory and sealed only."""
|
|
114
|
+
|
|
115
|
+
access_token: str = field(repr=False)
|
|
116
|
+
refresh_token: str | None = field(repr=False)
|
|
117
|
+
expires_at: datetime | None
|
|
118
|
+
token_endpoint: str
|
|
119
|
+
client_id: str
|
|
120
|
+
client_secret_slot: str | None
|
|
121
|
+
|
|
122
|
+
def encode(self) -> str:
|
|
123
|
+
return TOKEN_SET_PREFIX + json.dumps(
|
|
124
|
+
{
|
|
125
|
+
"access_token": self.access_token,
|
|
126
|
+
"refresh_token": self.refresh_token,
|
|
127
|
+
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
|
128
|
+
"token_endpoint": self.token_endpoint,
|
|
129
|
+
"client_id": self.client_id,
|
|
130
|
+
"client_secret_slot": self.client_secret_slot,
|
|
131
|
+
},
|
|
132
|
+
separators=(",", ":"),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def decode(cls, value: str) -> TokenSet | None:
|
|
137
|
+
"""The token set an authored slot value holds, or None for anything malformed.
|
|
138
|
+
|
|
139
|
+
Validated although only an authored value reaches here: the endpoint is where a
|
|
140
|
+
refresh sends the refresh token and the client secret, so it meets the same https
|
|
141
|
+
rule an :class:`OAuthClient` does, and a body this Runner cannot read must cost one
|
|
142
|
+
slot, never the beat.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
if not value.startswith(TOKEN_SET_PREFIX):
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
body = _TokenSetBody.model_validate_json(value[len(TOKEN_SET_PREFIX) :])
|
|
149
|
+
except ValidationError:
|
|
150
|
+
return None
|
|
151
|
+
return cls(**body.model_dump())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(slots=True)
|
|
155
|
+
class _Pending:
|
|
156
|
+
contract_id: str
|
|
157
|
+
slot: str
|
|
158
|
+
client: OAuthClient
|
|
159
|
+
redirect_uri: str
|
|
160
|
+
started_at: datetime
|
|
161
|
+
verifier: str = field(repr=False)
|
|
162
|
+
state: str = field(repr=False)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(slots=True)
|
|
166
|
+
class _Held:
|
|
167
|
+
tokens: TokenSet
|
|
168
|
+
version: int
|
|
169
|
+
# The ciphertext this Runner sealed and has not yet seen the ack echo back. Re-sent
|
|
170
|
+
# every beat until it is, so a lost heartbeat cannot lose a refreshed token.
|
|
171
|
+
upload: SealedCredential | None = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class OAuthConnectors:
|
|
175
|
+
"""Every OAuth step on this Runner, driven by the heartbeat (``service``)."""
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
*,
|
|
180
|
+
sealed: SealedCredentialStream,
|
|
181
|
+
provider: OAuthProvider,
|
|
182
|
+
credentials: CredentialResolver,
|
|
183
|
+
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
184
|
+
) -> None:
|
|
185
|
+
self._sealed = sealed
|
|
186
|
+
self._provider = provider
|
|
187
|
+
self._credentials = credentials
|
|
188
|
+
self._clock = clock
|
|
189
|
+
self._pending: dict[str, _Pending] = {}
|
|
190
|
+
self._held: dict[SlotKey, _Held] = {}
|
|
191
|
+
# The version a Disconnect saw; an ack still carrying that ciphertext is not
|
|
192
|
+
# re-adopted, and a reconnect seals above it.
|
|
193
|
+
self._dropped: dict[SlotKey, int] = {}
|
|
194
|
+
# A failed refresh is not retried every beat; a new version, or a 401, retries it.
|
|
195
|
+
self._failed: set[SlotKey] = set()
|
|
196
|
+
self._authorizations: list[OAuthAuthorizeUrl] = []
|
|
197
|
+
self._outcomes: list[OAuthOutcome] = []
|
|
198
|
+
self._lock = asyncio.Lock()
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------ the envelope
|
|
201
|
+
|
|
202
|
+
def take_authorizations(self) -> list[OAuthAuthorizeUrl]:
|
|
203
|
+
taken, self._authorizations = self._authorizations, []
|
|
204
|
+
return taken
|
|
205
|
+
|
|
206
|
+
def take_outcomes(self) -> list[OAuthOutcome]:
|
|
207
|
+
taken, self._outcomes = self._outcomes, []
|
|
208
|
+
return taken
|
|
209
|
+
|
|
210
|
+
def uploads(self) -> list[SealedCredential]:
|
|
211
|
+
"""Token ciphertext the control plane has not stored yet. Not drained (see _Held)."""
|
|
212
|
+
|
|
213
|
+
return [held.upload for _, held in sorted(self._held.items()) if held.upload is not None]
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------ the ack
|
|
216
|
+
|
|
217
|
+
async def apply(
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
starts: list[OAuthStart],
|
|
221
|
+
codes: list[SealedOAuthCode],
|
|
222
|
+
disconnects: list[OAuthDisconnect],
|
|
223
|
+
) -> None:
|
|
224
|
+
"""One ack's OAuth items. Disconnects first: a slot connected and disconnected in
|
|
225
|
+
quick succession must end disconnected, whatever order the platform queued them."""
|
|
226
|
+
|
|
227
|
+
async with self._lock:
|
|
228
|
+
for disconnect in disconnects:
|
|
229
|
+
await self._disconnect(disconnect)
|
|
230
|
+
for start in starts:
|
|
231
|
+
self._start(start)
|
|
232
|
+
for code in codes:
|
|
233
|
+
await self._exchange(code)
|
|
234
|
+
|
|
235
|
+
def _start(self, start: OAuthStart) -> None:
|
|
236
|
+
verifier = secrets.token_urlsafe(64)
|
|
237
|
+
state = secrets.token_urlsafe(32)
|
|
238
|
+
self._pending[start.authorization_id] = _Pending(
|
|
239
|
+
contract_id=str(start.contract_id),
|
|
240
|
+
slot=start.slot,
|
|
241
|
+
client=start.client,
|
|
242
|
+
redirect_uri=start.redirect_uri,
|
|
243
|
+
started_at=self._clock(),
|
|
244
|
+
verifier=verifier,
|
|
245
|
+
state=state,
|
|
246
|
+
)
|
|
247
|
+
while len(self._pending) > _PENDING_MAX:
|
|
248
|
+
self._pending.pop(next(iter(self._pending)))
|
|
249
|
+
query = {
|
|
250
|
+
"response_type": "code",
|
|
251
|
+
"client_id": start.client.client_id,
|
|
252
|
+
"redirect_uri": start.redirect_uri,
|
|
253
|
+
"state": state,
|
|
254
|
+
"code_challenge": _challenge(verifier),
|
|
255
|
+
"code_challenge_method": "S256",
|
|
256
|
+
}
|
|
257
|
+
if start.client.scopes:
|
|
258
|
+
query["scope"] = " ".join(start.client.scopes)
|
|
259
|
+
self._authorizations.append(
|
|
260
|
+
OAuthAuthorizeUrl(
|
|
261
|
+
contract_id=start.contract_id,
|
|
262
|
+
slot=start.slot,
|
|
263
|
+
authorization_id=start.authorization_id,
|
|
264
|
+
authorize_url=_with_query(start.client.authorize_endpoint, query),
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
async def _exchange(self, sealed: SealedOAuthCode) -> None:
|
|
269
|
+
authorization_id = sealed.authorization_id
|
|
270
|
+
pending = self._pending.get(authorization_id)
|
|
271
|
+
if pending is None or pending.contract_id != str(sealed.contract_id):
|
|
272
|
+
self._report(str(sealed.contract_id), None, authorization_id, OAuthOutcomeKind.UNKNOWN)
|
|
273
|
+
return
|
|
274
|
+
if self._clock() - pending.started_at > OAUTH_WINDOW:
|
|
275
|
+
del self._pending[authorization_id]
|
|
276
|
+
self._report(
|
|
277
|
+
pending.contract_id, pending.slot, authorization_id, OAuthOutcomeKind.EXPIRED
|
|
278
|
+
)
|
|
279
|
+
return
|
|
280
|
+
# A wrong `state` leaves the authorisation waiting: the code was injected, or is
|
|
281
|
+
# another tab's, and either way the person's own callback can still land.
|
|
282
|
+
if not hmac.compare_digest(sealed.state.encode(), pending.state.encode()):
|
|
283
|
+
self._report(
|
|
284
|
+
pending.contract_id,
|
|
285
|
+
pending.slot,
|
|
286
|
+
authorization_id,
|
|
287
|
+
OAuthOutcomeKind.STATE_MISMATCH,
|
|
288
|
+
)
|
|
289
|
+
return
|
|
290
|
+
code = self._sealed.open_oauth_code(sealed)
|
|
291
|
+
if code is None:
|
|
292
|
+
self._report(
|
|
293
|
+
pending.contract_id, pending.slot, authorization_id, OAuthOutcomeKind.UNOPENABLE
|
|
294
|
+
)
|
|
295
|
+
return
|
|
296
|
+
# Spent from here on, whatever the provider says: a code is single-use at the
|
|
297
|
+
# provider, and the verifier must not outlive the one exchange it was made for.
|
|
298
|
+
del self._pending[authorization_id]
|
|
299
|
+
client = pending.client
|
|
300
|
+
form = {
|
|
301
|
+
"grant_type": "authorization_code",
|
|
302
|
+
"code": code,
|
|
303
|
+
"redirect_uri": pending.redirect_uri,
|
|
304
|
+
"client_id": client.client_id,
|
|
305
|
+
"code_verifier": pending.verifier,
|
|
306
|
+
}
|
|
307
|
+
del code
|
|
308
|
+
key = (pending.contract_id, pending.slot)
|
|
309
|
+
response = await self._post(key, client.client_secret_slot, client.token_endpoint, form)
|
|
310
|
+
if response is None:
|
|
311
|
+
self._report(*key, authorization_id, OAuthOutcomeKind.CLIENT_SECRET_MISSING)
|
|
312
|
+
return
|
|
313
|
+
tokens = _token_set(response, client=client, now=self._clock())
|
|
314
|
+
if tokens is None:
|
|
315
|
+
self._report(
|
|
316
|
+
*key,
|
|
317
|
+
authorization_id,
|
|
318
|
+
OAuthOutcomeKind.EXCHANGE_FAILED,
|
|
319
|
+
provider_error=_provider_error(response),
|
|
320
|
+
)
|
|
321
|
+
return
|
|
322
|
+
outcome = self._seal(key, tokens)
|
|
323
|
+
self._report(*key, authorization_id, outcome)
|
|
324
|
+
|
|
325
|
+
async def _disconnect(self, disconnect: OAuthDisconnect) -> None:
|
|
326
|
+
key = (str(disconnect.contract_id), disconnect.slot)
|
|
327
|
+
held = self._held.pop(key, None)
|
|
328
|
+
self._failed.discard(key)
|
|
329
|
+
self._dropped[key] = max(
|
|
330
|
+
held.version if held is not None else 0, self._sealed.version(*key) or 0
|
|
331
|
+
)
|
|
332
|
+
for authorization_id in [
|
|
333
|
+
authorization_id
|
|
334
|
+
for authorization_id, pending in self._pending.items()
|
|
335
|
+
if (pending.contract_id, pending.slot) == key
|
|
336
|
+
]:
|
|
337
|
+
del self._pending[authorization_id]
|
|
338
|
+
client = disconnect.client
|
|
339
|
+
if held is None or client.revocation_endpoint is None:
|
|
340
|
+
self._report(*key, None, OAuthOutcomeKind.DROPPED)
|
|
341
|
+
return
|
|
342
|
+
# The refresh token, where there is one: RFC 7009 §2.1 lets the provider end the
|
|
343
|
+
# whole grant from it, which an access token's revocation does not promise.
|
|
344
|
+
token, hint = (
|
|
345
|
+
(held.tokens.refresh_token, "refresh_token")
|
|
346
|
+
if held.tokens.refresh_token
|
|
347
|
+
else (held.tokens.access_token, "access_token")
|
|
348
|
+
)
|
|
349
|
+
form = {"token": token, "token_type_hint": hint, "client_id": client.client_id}
|
|
350
|
+
response = await self._post(
|
|
351
|
+
key, client.client_secret_slot, client.revocation_endpoint, form
|
|
352
|
+
)
|
|
353
|
+
if response is None:
|
|
354
|
+
self._report(*key, None, OAuthOutcomeKind.CLIENT_SECRET_MISSING)
|
|
355
|
+
elif response.status == 200:
|
|
356
|
+
self._report(*key, None, OAuthOutcomeKind.REVOKED)
|
|
357
|
+
else:
|
|
358
|
+
self._report(
|
|
359
|
+
*key,
|
|
360
|
+
None,
|
|
361
|
+
OAuthOutcomeKind.REVOKE_FAILED,
|
|
362
|
+
provider_error=_provider_error(response),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# ------------------------------------------------------------------ refresh
|
|
366
|
+
|
|
367
|
+
async def before_beat(self) -> None:
|
|
368
|
+
"""Forget authorisations past their window, then refresh every token set within
|
|
369
|
+
:data:`REFRESH_MARGIN` of expiry (step 5) -- so the upload rides this beat."""
|
|
370
|
+
|
|
371
|
+
async with self._lock:
|
|
372
|
+
now = self._clock()
|
|
373
|
+
for authorization_id, pending in list(self._pending.items()):
|
|
374
|
+
if now - pending.started_at > OAUTH_WINDOW:
|
|
375
|
+
del self._pending[authorization_id]
|
|
376
|
+
self._report(
|
|
377
|
+
pending.contract_id,
|
|
378
|
+
pending.slot,
|
|
379
|
+
authorization_id,
|
|
380
|
+
OAuthOutcomeKind.EXPIRED,
|
|
381
|
+
)
|
|
382
|
+
for key, held in sorted(self._held.items()):
|
|
383
|
+
expires_at = held.tokens.expires_at
|
|
384
|
+
if key in self._failed or held.tokens.refresh_token is None:
|
|
385
|
+
continue
|
|
386
|
+
if expires_at is not None and expires_at - now <= REFRESH_MARGIN:
|
|
387
|
+
await self._refresh(key)
|
|
388
|
+
|
|
389
|
+
async def refresh(self, contract_id: UUID | str, slot: str) -> bool:
|
|
390
|
+
"""Refresh now, because a provider answered 401 to the access token (step 5).
|
|
391
|
+
|
|
392
|
+
Retries a slot whose last refresh failed, unlike :meth:`before_beat`: a 401 is new
|
|
393
|
+
information that the token is dead.
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
async with self._lock:
|
|
397
|
+
return await self._refresh((str(contract_id), slot))
|
|
398
|
+
|
|
399
|
+
async def _refresh(self, key: SlotKey) -> bool:
|
|
400
|
+
held = self._held.get(key)
|
|
401
|
+
if held is None or held.tokens.refresh_token is None:
|
|
402
|
+
return False
|
|
403
|
+
tokens = held.tokens
|
|
404
|
+
form = {
|
|
405
|
+
"grant_type": "refresh_token",
|
|
406
|
+
"refresh_token": tokens.refresh_token,
|
|
407
|
+
"client_id": tokens.client_id,
|
|
408
|
+
}
|
|
409
|
+
response = await self._post(key, tokens.client_secret_slot, tokens.token_endpoint, form)
|
|
410
|
+
if response is None:
|
|
411
|
+
self._failed.add(key)
|
|
412
|
+
self._report(*key, None, OAuthOutcomeKind.CLIENT_SECRET_MISSING)
|
|
413
|
+
return False
|
|
414
|
+
fresh = _token_set(response, client=None, now=self._clock(), previous=tokens)
|
|
415
|
+
if fresh is None:
|
|
416
|
+
self._failed.add(key)
|
|
417
|
+
self._report(
|
|
418
|
+
*key,
|
|
419
|
+
None,
|
|
420
|
+
OAuthOutcomeKind.REFRESH_FAILED,
|
|
421
|
+
provider_error=_provider_error(response),
|
|
422
|
+
)
|
|
423
|
+
return False
|
|
424
|
+
outcome = self._seal(key, fresh)
|
|
425
|
+
if outcome is OAuthOutcomeKind.CONNECTED:
|
|
426
|
+
outcome = OAuthOutcomeKind.REFRESHED
|
|
427
|
+
else:
|
|
428
|
+
self._failed.add(key)
|
|
429
|
+
self._report(*key, None, outcome)
|
|
430
|
+
return outcome is OAuthOutcomeKind.REFRESHED
|
|
431
|
+
|
|
432
|
+
# ------------------------------------------------------------------ the resolver
|
|
433
|
+
|
|
434
|
+
def resolved(self) -> dict[SlotKey, str]:
|
|
435
|
+
"""What the resolver serves: every opened slot, a token set as its access token.
|
|
436
|
+
|
|
437
|
+
Also where the ack's token sets are adopted. A version at or above what this
|
|
438
|
+
Runner holds replaces it -- another Runner of the installation refreshed, or the
|
|
439
|
+
upload landed; a lower one is the ack not yet carrying this Runner's own upload
|
|
440
|
+
and is ignored. A slot the ack no longer carries is dropped once its upload was
|
|
441
|
+
confirmed, which is how a Disconnect handled by another Runner reaches this one.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
served: dict[SlotKey, str] = {}
|
|
445
|
+
carried: set[SlotKey] = set()
|
|
446
|
+
for key, value in self._sealed.plaintext.items():
|
|
447
|
+
# Only a value this installation authored is ever read as a token set: its
|
|
448
|
+
# endpoint and client secret slot steer where the Runner sends a secret, and a
|
|
449
|
+
# funder can seal anything to the slot. Any other value is served as delivered.
|
|
450
|
+
body = self._sealed.authored(*key, value)
|
|
451
|
+
if body is None:
|
|
452
|
+
served[key] = value
|
|
453
|
+
continue
|
|
454
|
+
tokens = TokenSet.decode(body)
|
|
455
|
+
if tokens is None:
|
|
456
|
+
_logger.warning("OAuth token set for %s/%s is unreadable; not served", *key)
|
|
457
|
+
continue
|
|
458
|
+
version = self._sealed.version(*key) or 0
|
|
459
|
+
if version <= self._dropped.get(key, 0):
|
|
460
|
+
continue
|
|
461
|
+
carried.add(key)
|
|
462
|
+
held = self._held.get(key)
|
|
463
|
+
if held is None or version >= held.version:
|
|
464
|
+
if held is None or held.version != version:
|
|
465
|
+
self._failed.discard(key)
|
|
466
|
+
self._held[key] = _Held(tokens=tokens, version=version)
|
|
467
|
+
for key in [key for key, held in self._held.items() if key not in carried]:
|
|
468
|
+
if self._held[key].upload is None:
|
|
469
|
+
del self._held[key]
|
|
470
|
+
for key, held in self._held.items():
|
|
471
|
+
served[key] = held.tokens.access_token
|
|
472
|
+
return served
|
|
473
|
+
|
|
474
|
+
# ------------------------------------------------------------------ helpers
|
|
475
|
+
|
|
476
|
+
def _seal(self, key: SlotKey, tokens: TokenSet) -> OAuthOutcomeKind:
|
|
477
|
+
held = self._held.get(key)
|
|
478
|
+
version = 1 + max(
|
|
479
|
+
held.version if held is not None else 0,
|
|
480
|
+
self._sealed.version(*key) or 0,
|
|
481
|
+
self._dropped.get(key, 0),
|
|
482
|
+
)
|
|
483
|
+
upload = self._sealed.seal_authored(*key, version=version, value=tokens.encode())
|
|
484
|
+
if len(upload.ciphertext) > CIPHERTEXT_MAX_CHARS:
|
|
485
|
+
return OAuthOutcomeKind.TOKEN_TOO_LARGE
|
|
486
|
+
self._held[key] = _Held(tokens=tokens, version=version, upload=upload)
|
|
487
|
+
self._failed.discard(key)
|
|
488
|
+
return OAuthOutcomeKind.CONNECTED
|
|
489
|
+
|
|
490
|
+
async def _post(
|
|
491
|
+
self, key: SlotKey, secret_slot: str | None, url: str, form: Mapping[str, str | None]
|
|
492
|
+
) -> ProviderResponse | None:
|
|
493
|
+
"""POST to the provider with client authentication; None if the secret is missing.
|
|
494
|
+
|
|
495
|
+
``client_secret_post`` (RFC 6749 §2.3.1): every provider that issues a secret
|
|
496
|
+
accepts it, and it keeps the secret out of a header a proxy might log.
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
body = {name: value for name, value in form.items() if value is not None}
|
|
500
|
+
if secret_slot is not None:
|
|
501
|
+
try:
|
|
502
|
+
credentials = self._credentials.resolve(contract_id=key[0], manifest=[secret_slot])
|
|
503
|
+
except UnresolvableCredentialReferenceError:
|
|
504
|
+
return None
|
|
505
|
+
body["client_secret"] = credentials.for_runner_hosted_server(secret_slot)
|
|
506
|
+
try:
|
|
507
|
+
return await self._provider.post_form(url, body)
|
|
508
|
+
except OAuthProviderError as error:
|
|
509
|
+
_logger.warning("OAuth provider unreachable for %s/%s: %s", *key, error)
|
|
510
|
+
return ProviderResponse(status=0)
|
|
511
|
+
|
|
512
|
+
def _report(
|
|
513
|
+
self,
|
|
514
|
+
contract_id: str,
|
|
515
|
+
slot: str | None,
|
|
516
|
+
authorization_id: str | None,
|
|
517
|
+
outcome: OAuthOutcomeKind,
|
|
518
|
+
*,
|
|
519
|
+
provider_error: str | None = None,
|
|
520
|
+
) -> None:
|
|
521
|
+
_logger.info("OAuth %s for %s/%s (%s)", outcome.value, contract_id, slot, authorization_id)
|
|
522
|
+
self._outcomes.append(
|
|
523
|
+
OAuthOutcome(
|
|
524
|
+
contract_id=UUID(contract_id),
|
|
525
|
+
slot=slot,
|
|
526
|
+
authorization_id=authorization_id,
|
|
527
|
+
outcome=outcome,
|
|
528
|
+
provider_error=provider_error,
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _challenge(verifier: str) -> str:
|
|
534
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
535
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _with_query(endpoint: str, query: Mapping[str, str]) -> str:
|
|
539
|
+
# An authorize endpoint may carry its own query (a tenant, an audience); RFC 6749
|
|
540
|
+
# §3.1 says to keep it and add ours.
|
|
541
|
+
parts = urlsplit(endpoint)
|
|
542
|
+
joined = f"{parts.query}&{urlencode(query)}" if parts.query else urlencode(query)
|
|
543
|
+
return urlunsplit(parts._replace(query=joined))
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _token_set(
|
|
547
|
+
response: ProviderResponse,
|
|
548
|
+
*,
|
|
549
|
+
client: OAuthClient | None,
|
|
550
|
+
now: datetime,
|
|
551
|
+
previous: TokenSet | None = None,
|
|
552
|
+
) -> TokenSet | None:
|
|
553
|
+
"""RFC 6749 §5.1, or None. A refresh that returns no new refresh token keeps the old
|
|
554
|
+
one (§6: the provider *may* issue a new one)."""
|
|
555
|
+
|
|
556
|
+
body = response.body
|
|
557
|
+
access_token = body.get("access_token")
|
|
558
|
+
if response.status != 200 or not isinstance(access_token, str) or not access_token:
|
|
559
|
+
return None
|
|
560
|
+
token_type = body.get("token_type")
|
|
561
|
+
if isinstance(token_type, str) and token_type.lower() != "bearer":
|
|
562
|
+
return None
|
|
563
|
+
refresh_token = body.get("refresh_token")
|
|
564
|
+
expires_in = body.get("expires_in")
|
|
565
|
+
expires_at = (
|
|
566
|
+
now + timedelta(seconds=expires_in)
|
|
567
|
+
if isinstance(expires_in, int) and not isinstance(expires_in, bool) and expires_in > 0
|
|
568
|
+
else None
|
|
569
|
+
)
|
|
570
|
+
if previous is not None:
|
|
571
|
+
return replace(
|
|
572
|
+
previous,
|
|
573
|
+
access_token=access_token,
|
|
574
|
+
refresh_token=refresh_token
|
|
575
|
+
if isinstance(refresh_token, str)
|
|
576
|
+
else previous.refresh_token,
|
|
577
|
+
expires_at=expires_at,
|
|
578
|
+
)
|
|
579
|
+
assert client is not None
|
|
580
|
+
return TokenSet(
|
|
581
|
+
access_token=access_token,
|
|
582
|
+
refresh_token=refresh_token if isinstance(refresh_token, str) else None,
|
|
583
|
+
expires_at=expires_at,
|
|
584
|
+
token_endpoint=client.token_endpoint,
|
|
585
|
+
client_id=client.client_id,
|
|
586
|
+
client_secret_slot=client.client_secret_slot,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _provider_error(response: ProviderResponse) -> str | None:
|
|
591
|
+
error = response.body.get("error")
|
|
592
|
+
return error if isinstance(error, str) and _PROVIDER_ERROR.match(error) else None
|
|
@@ -34,6 +34,7 @@ from __future__ import annotations
|
|
|
34
34
|
|
|
35
35
|
import base64
|
|
36
36
|
import binascii
|
|
37
|
+
import hmac
|
|
37
38
|
import json
|
|
38
39
|
import logging
|
|
39
40
|
import secrets
|
|
@@ -55,6 +56,7 @@ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
|
55
56
|
|
|
56
57
|
from agentic_runner.auth_mode import SETUP_TOKEN_REFUSED_RULE, is_setup_token
|
|
57
58
|
from agentic_runner.private_state import private_read, private_write
|
|
59
|
+
from agentic_runner_contracts.oauth_connector import SealedOAuthCode
|
|
58
60
|
from agentic_runner_contracts.runner_registration import RecipientKey
|
|
59
61
|
from agentic_runner_contracts.sealed_credential import (
|
|
60
62
|
RENEWAL_INTERVAL,
|
|
@@ -81,6 +83,12 @@ __all__ = [
|
|
|
81
83
|
|
|
82
84
|
RECIPIENT_KEY_FILENAME: Final[str] = "recipient-key.json"
|
|
83
85
|
|
|
86
|
+
# What marks a slot value as one this installation sealed for itself, rather than one a
|
|
87
|
+
# funder delivered: the prefix, then an HMAC keyed off the Recipient private key
|
|
88
|
+
# (:meth:`SealedCredentialStream.authored`). Versioned like a seal domain.
|
|
89
|
+
AUTHORED_PREFIX: Final[str] = "agentic-os/runner-authored/v1:"
|
|
90
|
+
AUTHORED_MAC_DOMAIN: Final[bytes] = b"agentic-os/runner-authored-mac/v1"
|
|
91
|
+
|
|
84
92
|
_PUBLIC_KEY_BYTES: Final[int] = 32
|
|
85
93
|
_KEY_BYTES: Final[int] = 32
|
|
86
94
|
_NONCE: Final[bytes] = bytes(12)
|
|
@@ -328,6 +336,35 @@ def _derive(shared: bytes, ephemeral_public: bytes, recipient_public: bytes) ->
|
|
|
328
336
|
).derive(shared)
|
|
329
337
|
|
|
330
338
|
|
|
339
|
+
def _authored_mac(pair: RecipientKeyPair, contract_id: str, slot: str, body: str) -> bytes:
|
|
340
|
+
# Its own HKDF domain, so the MAC key shares nothing with any seal's AEAD key. A
|
|
341
|
+
# contract id is a UUID and a slot name has no NUL, so the joined message is unambiguous.
|
|
342
|
+
mac_key = HKDF(
|
|
343
|
+
algorithm=hashes.SHA256(), length=_KEY_BYTES, salt=b"", info=AUTHORED_MAC_DOMAIN
|
|
344
|
+
).derive(_raw_key(pair.private_key, "private"))
|
|
345
|
+
message = "\x00".join((contract_id, slot, body)).encode("utf-8")
|
|
346
|
+
return hmac.digest(mac_key, message, "sha256")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _tag_authored(pair: RecipientKeyPair, contract_id: str, slot: str, body: str) -> str:
|
|
350
|
+
tag = base64.urlsafe_b64encode(_authored_mac(pair, contract_id, slot, body)).decode("ascii")
|
|
351
|
+
return f"{AUTHORED_PREFIX}{tag}:{body}"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _untag_authored(pair: RecipientKeyPair, contract_id: str, slot: str, value: str) -> str | None:
|
|
355
|
+
if not value.startswith(AUTHORED_PREFIX):
|
|
356
|
+
return None
|
|
357
|
+
tag, separator, body = value[len(AUTHORED_PREFIX) :].partition(":")
|
|
358
|
+
if not separator:
|
|
359
|
+
return None
|
|
360
|
+
try:
|
|
361
|
+
claimed = base64.urlsafe_b64decode(tag.encode("ascii"))
|
|
362
|
+
except (binascii.Error, ValueError):
|
|
363
|
+
return None
|
|
364
|
+
expected = _authored_mac(pair, contract_id, slot, body)
|
|
365
|
+
return body if hmac.compare_digest(claimed, expected) else None
|
|
366
|
+
|
|
367
|
+
|
|
331
368
|
def _public_key(value: str) -> X25519PublicKey:
|
|
332
369
|
return X25519PublicKey.from_public_bytes(_raw_key(value, "public"))
|
|
333
370
|
|
|
@@ -494,7 +531,7 @@ class SealedCredentialStream:
|
|
|
494
531
|
binding=delivery_binding(
|
|
495
532
|
contract_id=contract_id, slot=slot, recipient_key_id=fresh.key_id
|
|
496
533
|
),
|
|
497
|
-
plaintext=value,
|
|
534
|
+
plaintext=self._retagged(contract_id, slot, value, fresh),
|
|
498
535
|
),
|
|
499
536
|
)
|
|
500
537
|
for (contract_id, slot), value in sorted(self._plaintext.items())
|
|
@@ -526,6 +563,63 @@ class SealedCredentialStream:
|
|
|
526
563
|
|
|
527
564
|
return self._open_with(sealed.recipient_key_id, sealed.binding(), sealed.ciphertext)
|
|
528
565
|
|
|
566
|
+
def open_oauth_code(self, sealed: SealedOAuthCode) -> str | None:
|
|
567
|
+
"""A relayed OAuth code, or None when it does not open (console-v2 issue 30).
|
|
568
|
+
|
|
569
|
+
Never held, like a sign-in code: the caller spends it on the token endpoint and
|
|
570
|
+
drops it.
|
|
571
|
+
"""
|
|
572
|
+
|
|
573
|
+
return self._open_with(sealed.recipient_key_id, sealed.binding(), sealed.ciphertext)
|
|
574
|
+
|
|
575
|
+
def seal_authored(
|
|
576
|
+
self, contract_id: str, slot: str, *, version: int, value: str
|
|
577
|
+
) -> SealedCredential:
|
|
578
|
+
"""Seal a value this Runner made to its own current key, as one slot's delivery.
|
|
579
|
+
|
|
580
|
+
How an OAuth token set leaves the Runner (console-v2 issue 30): bound to the same
|
|
581
|
+
``(contract, slot, key)`` triple a funder's delivery is, so every Runner of the
|
|
582
|
+
installation opens it through :meth:`apply` and no other installation can. Tagged
|
|
583
|
+
so :meth:`authored` can tell it from a funder's value in the same slot.
|
|
584
|
+
"""
|
|
585
|
+
|
|
586
|
+
key = self._keys.current()
|
|
587
|
+
return SealedCredential(
|
|
588
|
+
contract_id=UUID(contract_id),
|
|
589
|
+
slot=slot,
|
|
590
|
+
recipient_key_id=key.key_id,
|
|
591
|
+
version=version,
|
|
592
|
+
ciphertext=seal(
|
|
593
|
+
public_key=key.public_key,
|
|
594
|
+
binding=delivery_binding(
|
|
595
|
+
contract_id=contract_id, slot=slot, recipient_key_id=key.key_id
|
|
596
|
+
),
|
|
597
|
+
plaintext=_tag_authored(key, contract_id, slot, value),
|
|
598
|
+
),
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
def authored(self, contract_id: str, slot: str, value: str) -> str | None:
|
|
602
|
+
"""The value :meth:`seal_authored` tagged, or None for anything a funder delivered.
|
|
603
|
+
|
|
604
|
+
Sealing is public-key, so anyone holding the Recipient public key -- every funder --
|
|
605
|
+
can seal a value to this slot that opens exactly as the Runner's own upload does.
|
|
606
|
+
Only the tag, keyed off the private half, says this installation wrote it. Checked
|
|
607
|
+
against the previous key too, so a value tagged before a renewal still verifies
|
|
608
|
+
until its re-seal (re-tagged in :meth:`renew`) is the one held.
|
|
609
|
+
"""
|
|
610
|
+
|
|
611
|
+
for pair in (self._keys.current(), self._keys.previous()):
|
|
612
|
+
if pair is None:
|
|
613
|
+
continue
|
|
614
|
+
body = _untag_authored(pair, contract_id, slot, value)
|
|
615
|
+
if body is not None:
|
|
616
|
+
return body
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
def _retagged(self, contract_id: str, slot: str, value: str, fresh: RecipientKeyPair) -> str:
|
|
620
|
+
body = self.authored(contract_id, slot, value)
|
|
621
|
+
return value if body is None else _tag_authored(fresh, contract_id, slot, body)
|
|
622
|
+
|
|
529
623
|
def _open(self, sealed: SealedCredential) -> str | None:
|
|
530
624
|
return self._open_with(sealed.recipient_key_id, sealed.binding(), sealed.ciphertext)
|
|
531
625
|
|
|
@@ -61,6 +61,7 @@ from agentic_runner.heartbeat_link import WAKE_DIVERGENCE, HeartbeatLink
|
|
|
61
61
|
from agentic_runner.hooks import AttemptFacts, HookName, HookRunner
|
|
62
62
|
from agentic_runner.integrations.git.workspace import LocalGitWorkspace
|
|
63
63
|
from agentic_runner.integrations.github.gh_client import GitHubAppClient
|
|
64
|
+
from agentic_runner.integrations.oauth import HttpOAuthProvider
|
|
64
65
|
from agentic_runner.lifecycle import LIFECYCLE_FILENAME, LifecycleOutbox
|
|
65
66
|
from agentic_runner.llm_proxy import (
|
|
66
67
|
LLM_SLOT_REFERENCES,
|
|
@@ -73,6 +74,7 @@ from agentic_runner.llm_proxy import (
|
|
|
73
74
|
)
|
|
74
75
|
from agentic_runner.mcp import ToolServerHealthLog
|
|
75
76
|
from agentic_runner.message_store import MESSAGES_DIR, MessageStore, TemporalWorkflowSignaller
|
|
77
|
+
from agentic_runner.oauth_connectors import OAuthConnectors
|
|
76
78
|
from agentic_runner.private_state import (
|
|
77
79
|
UnsafeStateError,
|
|
78
80
|
ensure_private_dir,
|
|
@@ -593,6 +595,8 @@ class ControlPlaneStream:
|
|
|
593
595
|
llm_slots: Mapping[str, LlmProvider] = field(default_factory=lambda: dict(LLM_SLOT_REFERENCES))
|
|
594
596
|
# Local-agents 05: the Claude Code sign-ins a relayed code is typed into.
|
|
595
597
|
sign_ins: ClaudeSignIns | None = None
|
|
598
|
+
# Console-v2 issue 30: the OAuth Connectors whose code, tokens and verifier stay here.
|
|
599
|
+
oauth: OAuthConnectors | None = None
|
|
596
600
|
# Reported once, on the beat after the relay: a lost beat loses the outcome, and the
|
|
597
601
|
# console's own wait on the sign-in is what tells the person.
|
|
598
602
|
_sign_in_outcomes: list[SignInCodeRelay] = field(default_factory=list)
|
|
@@ -678,6 +682,8 @@ class ControlPlaneStream:
|
|
|
678
682
|
self._observe_sleep(now)
|
|
679
683
|
lifecycle = self.lifecycle.pending() if self.lifecycle is not None else []
|
|
680
684
|
renewed, resealed = self.sealed.renew(now)
|
|
685
|
+
if self.oauth is not None:
|
|
686
|
+
await self.oauth.before_beat()
|
|
681
687
|
envelope = HeartbeatEnvelope(
|
|
682
688
|
runner_version=runner_version,
|
|
683
689
|
contracts_version=contracts_version,
|
|
@@ -704,6 +710,9 @@ class ControlPlaneStream:
|
|
|
704
710
|
source_status=self.sources.statuses() if self.sources is not None else [],
|
|
705
711
|
tool_servers=self.tool_servers.latest() if self.tool_servers is not None else [],
|
|
706
712
|
sign_in_codes=self._sign_in_outcomes,
|
|
713
|
+
oauth_authorizations=self.oauth.take_authorizations() if self.oauth else [],
|
|
714
|
+
oauth_tokens=self.oauth.uploads() if self.oauth else [],
|
|
715
|
+
oauth_outcomes=self.oauth.take_outcomes() if self.oauth else [],
|
|
707
716
|
)
|
|
708
717
|
self._sign_in_outcomes = []
|
|
709
718
|
ack = await self.client.heartbeat(self.state, envelope)
|
|
@@ -711,6 +720,12 @@ class ControlPlaneStream:
|
|
|
711
720
|
self.sources.assign(ack.user_sources)
|
|
712
721
|
await self._deliver_transcripts(ack)
|
|
713
722
|
await self._relay_sign_in_codes(ack)
|
|
723
|
+
if self.oauth is not None:
|
|
724
|
+
# Before the sealed values are applied: a Disconnect revokes with the tokens
|
|
725
|
+
# this Runner holds, and the same ack may already have withdrawn their ciphertext.
|
|
726
|
+
await self.oauth.apply(
|
|
727
|
+
starts=ack.oauth_starts, codes=ack.oauth_codes, disconnects=ack.oauth_disconnects
|
|
728
|
+
)
|
|
714
729
|
if self.lifecycle is not None:
|
|
715
730
|
self.lifecycle.acknowledge(lifecycle)
|
|
716
731
|
if self.heartbeat_stamp is not None:
|
|
@@ -782,7 +797,7 @@ class ControlPlaneStream:
|
|
|
782
797
|
def _sync_delivered(self) -> None:
|
|
783
798
|
"""The resolver holds exactly what the last ack let this installation open."""
|
|
784
799
|
|
|
785
|
-
plaintext = self.sealed.plaintext
|
|
800
|
+
plaintext = self.oauth.resolved() if self.oauth is not None else self.sealed.plaintext
|
|
786
801
|
for (contract_id, slot), value in plaintext.items():
|
|
787
802
|
self.credentials.deliver(contract_id, slot, value)
|
|
788
803
|
for contract_id, slot in self._delivered - set(plaintext):
|
|
@@ -1074,6 +1089,9 @@ async def _serve_registered(
|
|
|
1074
1089
|
stream.messages = messages
|
|
1075
1090
|
sign_ins = ClaudeSignIns(build_contract_isolation(settings, can_separate_uids=can_change_uid))
|
|
1076
1091
|
stream.sign_ins = sign_ins
|
|
1092
|
+
stream.oauth = OAuthConnectors(
|
|
1093
|
+
sealed=stream.sealed, provider=HttpOAuthProvider(), credentials=credentials
|
|
1094
|
+
)
|
|
1077
1095
|
tool_servers = ToolServerHealthLog()
|
|
1078
1096
|
stream.tool_servers = tool_servers
|
|
1079
1097
|
link = HeartbeatLink(stream, ceilings=proxy.ceilings)
|
|
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
|
|
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
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/contracts.py
RENAMED
|
File without changes
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/evidence.py
RENAMED
|
File without changes
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/fake_workspace.py
RENAMED
|
File without changes
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/workspace.py
RENAMED
|
File without changes
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/auth.py
RENAMED
|
File without changes
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/fake_client.py
RENAMED
|
File without changes
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/gh_client.py
RENAMED
|
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
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/runtime/verifier_command.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/test_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/_runtime_support.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/contract_device_login.py
RENAMED
|
File without changes
|
{agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/contract_isolation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|