agentic-runner 3.7.0__tar.gz → 3.9.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.9.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/activities.py +53 -1
  4. agentic_runner-3.9.0/src/agentic_runner/integrations/oauth/__init__.py +68 -0
  5. agentic_runner-3.9.0/src/agentic_runner/integrations/oauth/fake_provider.py +134 -0
  6. agentic_runner-3.9.0/src/agentic_runner/oauth_connectors.py +592 -0
  7. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/sealed_box.py +95 -1
  8. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/service.py +57 -4
  9. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/_runtime_support.py +1 -0
  10. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/acp_runtime.py +123 -19
  11. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/agent_runtime.py +3 -0
  12. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/settings.py +10 -0
  13. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/.gitignore +0 -0
  14. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/LICENSE +0 -0
  15. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/README.md +0 -0
  16. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/pyproject.toml +0 -0
  17. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/attempts.py +0 -0
  18. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/auth_mode.py +0 -0
  19. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/build.py +0 -0
  20. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/callback.py +0 -0
  21. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/child_watcher.py +0 -0
  22. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/cli.py +0 -0
  23. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/cli_floor.py +0 -0
  24. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/config.py +0 -0
  25. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/credentials.py +0 -0
  26. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/device_login_activities.py +0 -0
  27. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/egress.py +0 -0
  28. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/harness_self_test.py +0 -0
  29. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/heartbeat_link.py +0 -0
  30. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/hooks.py +0 -0
  31. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/host_store.py +0 -0
  32. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/__init__.py +0 -0
  33. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  34. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  35. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  36. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  37. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  38. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  39. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  40. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  41. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  42. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/lifecycle.py +0 -0
  43. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/llm_proxy.py +0 -0
  44. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/mcp.py +0 -0
  45. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/message_store.py +0 -0
  46. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/private_state.py +0 -0
  47. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/py.typed +0 -0
  48. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  49. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/registration.py +0 -0
  50. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/runtime/__init__.py +0 -0
  51. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  52. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/__init__.py +0 -0
  53. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/__main__.py +0 -0
  54. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/control_plane.py +0 -0
  55. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/plugin.py +0 -0
  56. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  57. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/testing/workflow.py +0 -0
  58. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/tiny_http.py +0 -0
  59. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/triage_activities.py +0 -0
  60. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/usage_windows.py +0 -0
  61. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/user_sources.py +0 -0
  62. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/__init__.py +0 -0
  63. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  64. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  65. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  66. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/command_policy.py +0 -0
  67. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
  68. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  69. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  70. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
  71. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  72. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  73. {agentic_runner-3.7.0 → agentic_runner-3.9.0}/src/agentic_runner/workers/skills.py +0 -0
  74. {agentic_runner-3.7.0 → agentic_runner-3.9.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.9.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.9.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -124,6 +124,7 @@ from agentic_runner.workers._runtime_support import (
124
124
  bound_text,
125
125
  bound_text_tail,
126
126
  )
127
+ from agentic_runner.workers.acp_runtime import ACP_BRIDGES, AcpRuntime
127
128
  from agentic_runner.workers.agent_runtime import (
128
129
  AgentRuntime,
129
130
  AuthMode,
@@ -595,6 +596,8 @@ class _RuntimeContextState:
595
596
  # PRD decision 12: what a runtime asked per command does when the command policy has
596
597
  # no answer. Carried on the same `command_policy` blob as the ceiling above.
597
598
  permission_fallback: PermissionFallback = PermissionFallback.DENY
599
+ # Local-agents 18: the Profile-named ACP command, for a kind with no pinned bridge.
600
+ acp_command: tuple[str, ...] = ()
598
601
  # Pushed on change and applied on receipt (ADR-0011 §11, PRD issue 44): the evaluator
599
602
  # is local, so a narrowing lands on the next verb this state is read for.
600
603
  grant_snapshot: GrantSnapshot = UNENFORCED_SNAPSHOT
@@ -2053,6 +2056,7 @@ class RunnerRalphActivities:
2053
2056
  egress_allow_list=attempt.egress_allow_list,
2054
2057
  auth_mode=attempt.auth_mode,
2055
2058
  permission_fallback=runtime_state.permission_fallback,
2059
+ acp_command=runtime_state.acp_command,
2056
2060
  prompt=attempt.skills_preamble
2057
2061
  + _directive_prompt(
2058
2062
  completion_criteria=runtime_state.completion_criteria,
@@ -3008,10 +3012,55 @@ class RunnerRalphActivities:
3008
3012
  Fails closed (local-agents 01): a kind this process does not serve fails the
3009
3013
  Directive non-retryably, with Evidence naming the kind and the Runner, and never
3010
3014
  falls back to another runtime -- routing should not have sent it here, and a
3011
- retry on this Runner would find the same registry.
3015
+ retry on this Runner would find the same registry. So does a Profile-named ACP
3016
+ command for a kind whose bridge the Runner pins (local-agents 18): the platform
3017
+ never sends one, and the pinned bridge is the only thing that runs for that kind --
3018
+ and, for any other kind, a command whose ``argv[0]`` is not the executable the host
3019
+ pinned for it.
3012
3020
  """
3013
3021
 
3022
+ if state.acp_command and state.cli_kind in ACP_BRIDGES:
3023
+ await _raise_recorded(
3024
+ ApplicationError(
3025
+ f"the Runner pins the ACP bridge for {state.cli_kind!r}; a Profile-named "
3026
+ "ACP command is refused for it",
3027
+ non_retryable=True,
3028
+ ),
3029
+ self._fastapi_client,
3030
+ work_record_id,
3031
+ source=AGENT_RUNTIME_EVIDENCE_SOURCE,
3032
+ actor=self._actor(),
3033
+ payload={
3034
+ "event": "directive.acp_command_refused",
3035
+ "cli_kind": state.cli_kind,
3036
+ # The executable's name only: its arguments may carry a secret.
3037
+ "executable": redact_secret_like_text(Path(state.acp_command[0]).name),
3038
+ },
3039
+ )
3014
3040
  runtime = self._agent_runtimes.get(state.cli_kind)
3041
+ command_refusal = (
3042
+ runtime.acp_command_refusal(state.acp_command)
3043
+ if isinstance(runtime, AcpRuntime)
3044
+ else None
3045
+ )
3046
+ if command_refusal is not None:
3047
+ await _raise_recorded(
3048
+ ApplicationError(command_refusal, non_retryable=True),
3049
+ self._fastapi_client,
3050
+ work_record_id,
3051
+ source=AGENT_RUNTIME_EVIDENCE_SOURCE,
3052
+ actor=self._actor(),
3053
+ payload={
3054
+ "event": "directive.acp_command_refused",
3055
+ "cli_kind": state.cli_kind,
3056
+ "executable": (
3057
+ redact_secret_like_text(Path(state.acp_command[0]).name)
3058
+ if state.acp_command
3059
+ else None
3060
+ ),
3061
+ "reason": command_refusal,
3062
+ },
3063
+ )
3015
3064
  if runtime is not None:
3016
3065
  return runtime
3017
3066
  served = sorted(self._agent_runtimes)
@@ -3425,6 +3474,7 @@ class RunnerRalphActivities:
3425
3474
  model=runtime_context.model,
3426
3475
  memory_limit_bytes=_profile_memory_limit_bytes(runtime_context.command_policy),
3427
3476
  permission_fallback=_profile_permission_fallback(runtime_context.command_policy),
3477
+ acp_command=tuple(runtime_context.acp_command or ()),
3428
3478
  grant_snapshot=snapshot,
3429
3479
  grant_snapshot_payload=snapshot_payload,
3430
3480
  persona_slug=runtime_context.persona_slug,
@@ -4699,6 +4749,7 @@ class RunnerRalphActivities:
4699
4749
  egress_allow_list=attempt.egress_allow_list,
4700
4750
  auth_mode=attempt.auth_mode,
4701
4751
  permission_fallback=state.permission_fallback,
4752
+ acp_command=state.acp_command,
4702
4753
  prompt=attempt.skills_preamble
4703
4754
  + _learning_prompt(
4704
4755
  request,
@@ -4960,6 +5011,7 @@ class RunnerRalphActivities:
4960
5011
  egress_allow_list=attempt.egress_allow_list,
4961
5012
  auth_mode=attempt.auth_mode,
4962
5013
  permission_fallback=state.permission_fallback,
5014
+ acp_command=state.acp_command,
4963
5015
  prompt=attempt.skills_preamble + await kind.prompt(state, prompt_notes),
4964
5016
  base_branch=request.base_ref,
4965
5017
  work_branch=state.work_branch,
@@ -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"})