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.
Files changed (74) hide show
  1. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/__init__.py +1 -1
  3. agentic_runner-3.8.0/src/agentic_runner/integrations/oauth/__init__.py +68 -0
  4. agentic_runner-3.8.0/src/agentic_runner/integrations/oauth/fake_provider.py +134 -0
  5. agentic_runner-3.8.0/src/agentic_runner/oauth_connectors.py +592 -0
  6. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/sealed_box.py +95 -1
  7. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/service.py +19 -1
  8. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/.gitignore +0 -0
  9. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/LICENSE +0 -0
  10. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/README.md +0 -0
  11. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/pyproject.toml +0 -0
  12. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/activities.py +0 -0
  13. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/attempts.py +0 -0
  14. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/auth_mode.py +0 -0
  15. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/build.py +0 -0
  16. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/callback.py +0 -0
  17. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/child_watcher.py +0 -0
  18. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/cli.py +0 -0
  19. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/cli_floor.py +0 -0
  20. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/config.py +0 -0
  21. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/credentials.py +0 -0
  22. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/device_login_activities.py +0 -0
  23. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/egress.py +0 -0
  24. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/harness_self_test.py +0 -0
  25. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/heartbeat_link.py +0 -0
  26. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/hooks.py +0 -0
  27. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/host_store.py +0 -0
  28. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/__init__.py +0 -0
  29. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  30. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  31. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  32. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  33. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  34. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  35. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  36. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  37. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  38. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/lifecycle.py +0 -0
  39. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/llm_proxy.py +0 -0
  40. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/mcp.py +0 -0
  41. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/message_store.py +0 -0
  42. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/private_state.py +0 -0
  43. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/py.typed +0 -0
  44. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  45. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/registration.py +0 -0
  46. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/runtime/__init__.py +0 -0
  47. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  48. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/__init__.py +0 -0
  49. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/__main__.py +0 -0
  50. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/control_plane.py +0 -0
  51. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/plugin.py +0 -0
  52. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  53. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/workflow.py +0 -0
  54. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/tiny_http.py +0 -0
  55. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/triage_activities.py +0 -0
  56. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/usage_windows.py +0 -0
  57. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/user_sources.py +0 -0
  58. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/__init__.py +0 -0
  59. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  60. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/acp_runtime.py +0 -0
  61. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
  62. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  63. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  64. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  65. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/command_policy.py +0 -0
  66. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
  67. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  68. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  69. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
  70. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  71. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  72. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/settings.py +0 -0
  73. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/skills.py +0 -0
  74. {agentic_runner-3.7.0 → agentic_runner-3.8.0}/src/agentic_runner/workstation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 3.7.0
3
+ Version: 3.8.0
4
4
  Summary: The Agentic OS Runner: an activity-only Temporal executor for workspaces, Agent Runtimes and verb seams
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -7,6 +7,6 @@ stay on the platform; this package imports none of them, and a CI partition test
7
7
  (``tests/test_package_partition.py``) fails the build if that changes.
8
8
  """
9
9
 
10
- __version__ = "3.7.0"
10
+ __version__ = "3.8.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -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