agentic-runner 3.6.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.6.0 → agentic_runner-3.8.0}/PKG-INFO +1 -1
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/activities.py +12 -0
- 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.6.0 → agentic_runner-3.8.0}/src/agentic_runner/sealed_box.py +95 -1
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/service.py +97 -2
- agentic_runner-3.8.0/src/agentic_runner/usage_windows.py +529 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/acp_runtime.py +15 -1
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/agent_runtime.py +4 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/harness_outcome.py +5 -5
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/.gitignore +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/LICENSE +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/README.md +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/pyproject.toml +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/auth_mode.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/build.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/cli_floor.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/config.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/device_login_activities.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/harness_self_test.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/private_state.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/registration.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/__init__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/__main__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/control_plane.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/plugin.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/test_conformance.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/testing/workflow.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/settings.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workers/skills.py +0 -0
- {agentic_runner-3.6.0 → agentic_runner-3.8.0}/src/agentic_runner/workstation.py +0 -0
|
@@ -118,6 +118,7 @@ from agentic_runner.message_store import MessageStore, WorkflowSignaller
|
|
|
118
118
|
from agentic_runner.registration import RunnerRegistrationError
|
|
119
119
|
from agentic_runner.runtime import verifier_command
|
|
120
120
|
from agentic_runner.runtime.verifier_command import CommandValidationError
|
|
121
|
+
from agentic_runner.usage_windows import UsageWindows
|
|
121
122
|
from agentic_runner.workers._runtime_support import (
|
|
122
123
|
RESERVED_DIRECTIVE_ENV,
|
|
123
124
|
bound_text,
|
|
@@ -174,6 +175,7 @@ from agentic_runner_contracts.activity_io import (
|
|
|
174
175
|
GitHubCallError,
|
|
175
176
|
GitHubCallFailure,
|
|
176
177
|
HarnessHold,
|
|
178
|
+
HarnessHoldKind,
|
|
177
179
|
LearningDirectiveInput,
|
|
178
180
|
LearningDirectiveOutput,
|
|
179
181
|
MemberDirectiveInput,
|
|
@@ -1616,10 +1618,13 @@ class RunnerRalphActivities:
|
|
|
1616
1618
|
tool_server_health: ToolServerHealthLog | None = None,
|
|
1617
1619
|
attempt_records: AttemptRecords | None = None,
|
|
1618
1620
|
directives_in_flight: DirectivesInFlight | None = None,
|
|
1621
|
+
usage_windows: UsageWindows | None = None,
|
|
1619
1622
|
) -> None:
|
|
1620
1623
|
self._fastapi_client = fastapi_client
|
|
1621
1624
|
# Read by the harness self-test, which never runs beside a Contract's Directive.
|
|
1622
1625
|
self._directives_in_flight = directives_in_flight or DirectivesInFlight()
|
|
1626
|
+
# Local-agents 10: None off a user-hosted Runner, which reports no usage windows.
|
|
1627
|
+
self._usage_windows = usage_windows
|
|
1623
1628
|
# Wall-clock source for per-Directive runtime, accrued against the Budget's
|
|
1624
1629
|
# wall-clock dimension (ADR-0007). Activities run outside the Temporal sandbox,
|
|
1625
1630
|
# so a real monotonic clock is allowed here (it must never be used in the
|
|
@@ -5273,6 +5278,13 @@ class RunnerRalphActivities:
|
|
|
5273
5278
|
error=result.error,
|
|
5274
5279
|
now=datetime.now(UTC),
|
|
5275
5280
|
)
|
|
5281
|
+
if self._usage_windows is not None and runtime_state.contract_id:
|
|
5282
|
+
self._usage_windows.after_directive(
|
|
5283
|
+
runtime_state.contract_id,
|
|
5284
|
+
runtime_state.cli_kind,
|
|
5285
|
+
result.usage_windows,
|
|
5286
|
+
usage_limit=hold is not None and hold.kind == HarnessHoldKind.USAGE_LIMIT,
|
|
5287
|
+
)
|
|
5276
5288
|
if hold is None:
|
|
5277
5289
|
return None
|
|
5278
5290
|
await self._fastapi_client.append_evidence(
|
|
@@ -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"})
|