agentic-runner 2.6.0__py3-none-any.whl

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 (54) hide show
  1. agentic_runner/__init__.py +12 -0
  2. agentic_runner/activities.py +4918 -0
  3. agentic_runner/callback.py +342 -0
  4. agentic_runner/child_watcher.py +66 -0
  5. agentic_runner/cli.py +416 -0
  6. agentic_runner/config.py +105 -0
  7. agentic_runner/credentials.py +252 -0
  8. agentic_runner/device_login_activities.py +79 -0
  9. agentic_runner/egress.py +243 -0
  10. agentic_runner/heartbeat_link.py +249 -0
  11. agentic_runner/hooks.py +455 -0
  12. agentic_runner/host_store.py +295 -0
  13. agentic_runner/integrations/__init__.py +0 -0
  14. agentic_runner/integrations/git/__init__.py +1 -0
  15. agentic_runner/integrations/git/contracts.py +198 -0
  16. agentic_runner/integrations/git/evidence.py +442 -0
  17. agentic_runner/integrations/git/fake_workspace.py +339 -0
  18. agentic_runner/integrations/git/workspace.py +921 -0
  19. agentic_runner/integrations/github/__init__.py +53 -0
  20. agentic_runner/integrations/github/auth.py +171 -0
  21. agentic_runner/integrations/github/fake_client.py +494 -0
  22. agentic_runner/integrations/github/gh_client.py +944 -0
  23. agentic_runner/lifecycle.py +48 -0
  24. agentic_runner/llm_proxy.py +937 -0
  25. agentic_runner/mcp.py +342 -0
  26. agentic_runner/message_store.py +341 -0
  27. agentic_runner/py.typed +0 -0
  28. agentic_runner/recipient_key_secret.py +134 -0
  29. agentic_runner/registration.py +363 -0
  30. agentic_runner/runtime/__init__.py +0 -0
  31. agentic_runner/runtime/verifier_command.py +344 -0
  32. agentic_runner/sealed_box.py +509 -0
  33. agentic_runner/service.py +1068 -0
  34. agentic_runner/tiny_http.py +133 -0
  35. agentic_runner/triage_activities.py +113 -0
  36. agentic_runner/user_sources.py +546 -0
  37. agentic_runner/workers/__init__.py +1 -0
  38. agentic_runner/workers/_runtime_support.py +388 -0
  39. agentic_runner/workers/agent_runtime.py +93 -0
  40. agentic_runner/workers/claude_runtime.py +226 -0
  41. agentic_runner/workers/codex_runtime.py +311 -0
  42. agentic_runner/workers/command_policy.py +250 -0
  43. agentic_runner/workers/contract_device_login.py +211 -0
  44. agentic_runner/workers/contract_isolation.py +500 -0
  45. agentic_runner/workers/fastapi_client.py +396 -0
  46. agentic_runner/workers/harness_usage.py +65 -0
  47. agentic_runner/workers/mcp_config.py +111 -0
  48. agentic_runner/workers/settings.py +314 -0
  49. agentic_runner/workstation.py +687 -0
  50. agentic_runner-2.6.0.dist-info/METADATA +49 -0
  51. agentic_runner-2.6.0.dist-info/RECORD +54 -0
  52. agentic_runner-2.6.0.dist-info/WHEEL +4 -0
  53. agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
  54. agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,252 @@
1
+ """Credential References on the Runner (PRD issue 48; map 22 A1-A2, 25 §11, 17 A4/A10).
2
+
3
+ The **norm**, and the thing this module is mostly about: the Contract's credential
4
+ manifest names each credential -- name, type, scope -- and the control plane holds those
5
+ names and nothing else. The value is installed by whoever hosts the Runner, in their own
6
+ store: a Kubernetes Secret mounted ``0400`` (issue 46) or the workstation store (issue
7
+ 47), under the reference's name. Endpoints are plain Profile config and are not secrets,
8
+ so they are nowhere near here.
9
+
10
+ Release 1's default for a **cross-party** value is hand-over (22 A2): the funder gives it
11
+ to the host operator out of band, who installs it as any other reference. That is a
12
+ legitimate answer, not a gap -- :mod:`agentic_runner.sealed_box` is the exception path for
13
+ a funder who does not host, and its opened values land in the same resolver below, under
14
+ the same reference name, precisely so nothing downstream can tell the two apart.
15
+
16
+ Two rules, and the second is the reason this is a module rather than a dictionary:
17
+
18
+ * **The manifest bounds what a Contract may resolve.** 17 asks how one Contract is kept
19
+ off another's credentials on a shared Runner; the structural answer is that resolution
20
+ is a lookup in the Contract's own manifest first and the host store second, so a
21
+ reference the Contract never declared is unresolvable however the host store is laid
22
+ out. An unknown reference fails the Directive **closed**, with Evidence naming it.
23
+ * **Values reach verb seams and Runner-hosted MCP servers only** (17 A10). Never the
24
+ Agent Runtime's environment -- that process is the one on the box that must hold no
25
+ org credential (ADR-0011 §9) -- and never a hook, which is repository-adjacent code the
26
+ Agent itself can influence. :class:`DirectiveCredentials` has two accessors and both
27
+ are named after the one place they may be spent; there is deliberately no ``env()``.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Iterable, Mapping
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Final, Protocol
36
+ from uuid import UUID
37
+
38
+ __all__ = [
39
+ "CREDENTIAL_RESOLVED_SOURCE",
40
+ "CREDENTIAL_UNRESOLVABLE_SOURCE",
41
+ "CredentialResolver",
42
+ "DirectiveCredentials",
43
+ "DirectoryCredentialStore",
44
+ "EmptyCredentialStore",
45
+ "FakeCredentialStore",
46
+ "HostCredentialStore",
47
+ "UnresolvableCredentialReferenceError",
48
+ ]
49
+
50
+ # The Evidence a Directive that failed closed on a reference appends (ids and the
51
+ # reference *name*, never a value and never a digest of one).
52
+ CREDENTIAL_UNRESOLVABLE_SOURCE: Final[str] = "runner.credential_unresolvable"
53
+ # The Evidence a Directive appends when its references resolved. Names only.
54
+ CREDENTIAL_RESOLVED_SOURCE: Final[str] = "runner.credential_resolved"
55
+
56
+
57
+ class HostCredentialStore(Protocol):
58
+ """Where the host operator installed the values. The Runner reads; it never writes."""
59
+
60
+ def get(self, reference: str) -> str | None: ...
61
+
62
+
63
+ class DirectoryCredentialStore:
64
+ """A mounted Secret or a workstation store: one file per Credential Reference.
65
+
66
+ The Kubernetes shape (issue 46) and the workstation shape (issue 47) are the same
67
+ shape -- a directory of ``0400`` files named after the reference -- so one reader
68
+ serves both and neither installer has to teach the Runner a format.
69
+
70
+ A reference is a single path segment (:meth:`_path` refuses anything else) because a
71
+ manifest is operator-authored data and a Contract must not be able to name
72
+ ``../another-contract/key``.
73
+ """
74
+
75
+ def __init__(self, root: Path) -> None:
76
+ self._root = root
77
+
78
+ def get(self, reference: str) -> str | None:
79
+ path = self._path(reference)
80
+ if path is None or not path.is_file():
81
+ return None
82
+ # A Secret written by `kubectl create secret --from-literal` and one written by
83
+ # an editor differ by exactly one trailing newline; stripping it is the
84
+ # difference between a working key and a 401 nobody can explain.
85
+ return path.read_text(encoding="utf-8").strip()
86
+
87
+ def _path(self, reference: str) -> Path | None:
88
+ if not reference or "/" in reference or reference in {".", ".."}:
89
+ return None
90
+ return self._root / reference
91
+
92
+
93
+ class FakeCredentialStore:
94
+ """The host store a test wires (``integrations/*/fake_*`` keep the same shape)."""
95
+
96
+ def __init__(self, values: Mapping[str, str] | None = None) -> None:
97
+ self._values = dict(values or {})
98
+
99
+ def get(self, reference: str) -> str | None:
100
+ return self._values.get(reference)
101
+
102
+ def put(self, reference: str, value: str) -> None:
103
+ self._values[reference] = value
104
+
105
+
106
+ class EmptyCredentialStore:
107
+ """The store a Runner with none configured behaves as: everything is uninstalled.
108
+
109
+ Not a null object for convenience -- it is the fail-closed reading. A Contract that
110
+ declares a manifest and lands on a Runner with no host store gets a refused Directive
111
+ naming the reference, instead of one that ran without the credential it was told to use.
112
+ """
113
+
114
+ def get(self, reference: str) -> str | None:
115
+ return None
116
+
117
+
118
+ class UnresolvableCredentialReferenceError(RuntimeError):
119
+ """A Directive named a Credential Reference it cannot have (22 A1's fail-closed half).
120
+
121
+ ``reason`` is ``not_in_manifest`` (the Contract never declared it) or
122
+ ``not_installed`` (declared, but the host has not put a value there yet, and no
123
+ funder has sealed one). Both fail the Directive; they differ in who fixes it, which
124
+ is why the Evidence carries the distinction.
125
+ """
126
+
127
+ def __init__(self, reference: str, *, contract_id: str | None, reason: str) -> None:
128
+ super().__init__(
129
+ f"Credential Reference {reference!r} is unresolvable for Contract "
130
+ f"{contract_id or 'unknown'}: {reason}"
131
+ )
132
+ self.reference = reference
133
+ self.contract_id = contract_id
134
+ self.reason = reason
135
+
136
+ def evidence(self) -> dict[str, object]:
137
+ """The Evidence payload naming the reference -- the whole point of failing here."""
138
+
139
+ return {
140
+ "event": "credential_reference.unresolvable",
141
+ "reference": self.reference,
142
+ "contract_id": self.contract_id,
143
+ "reason": self.reason,
144
+ }
145
+
146
+
147
+ @dataclass(frozen=True, slots=True)
148
+ class DirectiveCredentials:
149
+ """The values one Directive may spend, resolved once at its start (22 A1).
150
+
151
+ Held for the life of the attempt and no longer. Both accessors are named after the
152
+ only two consumers 17 A10 admits; there is no accessor that yields an environment
153
+ mapping, because the two processes that take one -- the Agent Runtime subprocess and
154
+ a Runner Hook -- are exactly the two that must never see a value.
155
+ """
156
+
157
+ contract_id: str | None
158
+ _values: Mapping[str, str]
159
+
160
+ def for_verb_seam(self, reference: str) -> str:
161
+ """The value a privileged verb seam spends (push, PR, review, merge)."""
162
+
163
+ return self._require(reference)
164
+
165
+ def for_runner_hosted_server(self, reference: str) -> str:
166
+ """The value a Runner-hosted MCP server is started with (issue 58)."""
167
+
168
+ return self._require(reference)
169
+
170
+ def references(self) -> tuple[str, ...]:
171
+ """The reference *names* resolved, for Evidence and for a server's config."""
172
+
173
+ return tuple(sorted(self._values))
174
+
175
+ def runner_hosted_server_env(self, references: Iterable[str]) -> dict[str, str]:
176
+ """The environment one Runner-hosted MCP server is started with (issue 58).
177
+
178
+ A *subset*, named by the server's own registry entry: a server declaring one
179
+ reference is handed that one, not the Contract's whole manifest. The Agent talks
180
+ to the server over the Runner's socket and never holds what the server holds,
181
+ which is the arrangement 17 A10 admits.
182
+ """
183
+
184
+ return {reference: self.for_runner_hosted_server(reference) for reference in references}
185
+
186
+ def _require(self, reference: str) -> str:
187
+ value = self._values.get(reference)
188
+ if value is None:
189
+ raise UnresolvableCredentialReferenceError(
190
+ reference, contract_id=self.contract_id, reason="not_in_manifest"
191
+ )
192
+ return value
193
+
194
+
195
+ class CredentialResolver:
196
+ """Resolves a Contract's manifest into values, at Directive time (22 A1, 25 §11).
197
+
198
+ Per Directive rather than per process, for the same reason the LLM slot is read per
199
+ request (22 A8): a value the host replaced or a funder re-delivered takes effect at
200
+ the next Directive rather than at the next Runner restart.
201
+
202
+ ``delivered`` is the sealed half's output -- ``{(contract_id, slot): plaintext}``,
203
+ opened by :class:`agentic_runner.sealed_box.RecipientKeyStore`'s private key and held
204
+ in memory only. It is consulted before the host store because a funder who delivered a
205
+ value is stating it is theirs to state; the host's own install is the fallback, which
206
+ is exactly the hand-over default.
207
+ """
208
+
209
+ def __init__(
210
+ self,
211
+ *,
212
+ store: HostCredentialStore,
213
+ delivered: Mapping[tuple[str, str], str] | None = None,
214
+ ) -> None:
215
+ self._store = store
216
+ self._delivered = dict(delivered or {})
217
+
218
+ @property
219
+ def host_store(self) -> HostCredentialStore:
220
+ """The host's own store, for the one reader that is not a Directive: a
221
+ user-connected Source's connector (PRD issue 50), whose Source row -- not a
222
+ Contract manifest -- names the reference, and whose value is its owner's own."""
223
+
224
+ return self._store
225
+
226
+ def deliver(self, contract_id: UUID | str, slot: str, value: str) -> None:
227
+ self._delivered[(str(contract_id), slot)] = value
228
+
229
+ def drop(self, contract_id: UUID | str, slot: str) -> None:
230
+ """Termination and revocation (22 A9): the value goes, it is not listed."""
231
+
232
+ self._delivered.pop((str(contract_id), slot), None)
233
+
234
+ def resolve(self, *, contract_id: str | None, manifest: Iterable[str]) -> DirectiveCredentials:
235
+ """Every reference the Contract declares, or the first failure, named.
236
+
237
+ Fails on the *first* unresolvable reference rather than collecting them: a
238
+ Directive that cannot have one of its credentials is not going to run, and a
239
+ partial resolution left lying around is a value held for no reason.
240
+ """
241
+
242
+ values: dict[str, str] = {}
243
+ for reference in manifest:
244
+ value = self._delivered.get((str(contract_id), reference))
245
+ if value is None:
246
+ value = self._store.get(reference)
247
+ if value is None:
248
+ raise UnresolvableCredentialReferenceError(
249
+ reference, contract_id=contract_id, reason="not_installed"
250
+ )
251
+ values[reference] = value
252
+ return DirectiveCredentials(contract_id=contract_id, _values=values)
@@ -0,0 +1,79 @@
1
+ """The Runner's activities behind the Contract device-login workflows (PRD issue 31).
2
+
3
+ Dispatched to ``runner.{runner_id}`` (the locality rule, ADR-0013 §2): the only place a
4
+ Contract's harness root lives is the Runner's own disk (ADR-0015 §4), so they ship with
5
+ the Runner distribution and the platform's workflow module only names them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from datetime import UTC, datetime
12
+
13
+ from temporalio import activity
14
+
15
+ from agentic_runner.workers.contract_device_login import ContractDeviceLogin
16
+ from agentic_runner.workers.contract_isolation import ContractIsolation
17
+ from agentic_runner_contracts.activity_io import (
18
+ ContractDeviceLoginInput,
19
+ ContractDeviceLoginResult,
20
+ ContractDeviceLoginStatusInput,
21
+ ContractDeviceLoginStatusResult,
22
+ )
23
+
24
+
25
+ class ContractDeviceLoginActivities:
26
+ """Temporal activities for one Contract's device-code sign-in: start it, and
27
+ separately check whether the funder has finished it."""
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ contract_isolation: ContractIsolation,
33
+ device_login: ContractDeviceLogin | None = None,
34
+ ) -> None:
35
+ self._contract_isolation = contract_isolation
36
+ # Injectable for tests; None falls through to the real `codex` subprocess.
37
+ self._device_login = device_login or ContractDeviceLogin(contract_isolation)
38
+
39
+ def activity_callables(self) -> list[Callable[..., object]]:
40
+ return [
41
+ self.sign_in_contract_device_login,
42
+ self.check_contract_device_login_status,
43
+ ]
44
+
45
+ @activity.defn(name="sign_in_contract_device_login")
46
+ async def sign_in_contract_device_login(
47
+ self, request: ContractDeviceLoginInput
48
+ ) -> ContractDeviceLoginResult:
49
+ prompt = await self._device_login.sign_in(
50
+ request.contract_id, runtime_kind=request.runtime_kind
51
+ )
52
+ return ContractDeviceLoginResult(
53
+ contract_id=prompt.contract_id,
54
+ runtime_kind=prompt.runtime_kind,
55
+ verification_uri=prompt.verification_uri,
56
+ user_code=prompt.user_code,
57
+ expires_at=prompt.expires_at.isoformat(),
58
+ )
59
+
60
+ @activity.defn(name="check_contract_device_login_status")
61
+ async def check_contract_device_login_status(
62
+ self, request: ContractDeviceLoginStatusInput
63
+ ) -> ContractDeviceLoginStatusResult:
64
+ present = self._device_login.token_present(
65
+ request.contract_id, runtime_kind=request.runtime_kind
66
+ )
67
+ delivered_at = None
68
+ if present:
69
+ mtime = self._device_login.token_delivered_at(
70
+ request.contract_id, runtime_kind=request.runtime_kind
71
+ )
72
+ if mtime is not None:
73
+ delivered_at = datetime.fromtimestamp(mtime, tz=UTC).isoformat()
74
+ return ContractDeviceLoginStatusResult(
75
+ contract_id=request.contract_id,
76
+ runtime_kind=request.runtime_kind,
77
+ token_present=present,
78
+ delivered_at=delivered_at,
79
+ )
@@ -0,0 +1,243 @@
1
+ """Scoped egress for one Directive (PRD issue 58, map 06 §4, ticket 05's open item).
2
+
3
+ ``CODEX_NETWORK_ACCESS`` was all-or-nothing: a Directive either reached the whole internet
4
+ or nothing, and a VPC-only Streamable HTTP MCP server needs neither. The Agent Runtime
5
+ Profile's ``network_policy`` now names the destinations a Directive may reach; the Runner
6
+ adds the ones it knows the Directive needs (the git remote, each granted HTTP MCP
7
+ server), and this forward proxy on loopback is where the list is enforced: every
8
+ Directive under a Profile allow-list is handed ``HTTPS_PROXY`` / ``HTTP_PROXY`` /
9
+ ``ALL_PROXY`` naming it, with the attempt's bearer as the proxy credential so another
10
+ Contract's uid on the same loopback cannot borrow this attempt's list.
11
+
12
+ What this is *not* is a kernel boundary. A process that ignores the proxy variables and
13
+ opens a raw socket is stopped only by what the host adds underneath (a pod
14
+ NetworkPolicy); a workstation adds nothing, which is why it reports its posture as
15
+ ``unenforced`` in the heartbeat rather than claiming the list binds.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import base64
22
+ import contextlib
23
+ import hmac
24
+ from collections.abc import Awaitable, Callable, Iterable
25
+ from fnmatch import fnmatchcase
26
+ from types import TracebackType
27
+ from typing import Final
28
+ from urllib.parse import urlsplit
29
+
30
+ from agentic_runner.tiny_http import HttpRequest, read_request, response_head
31
+
32
+ __all__ = [
33
+ "EGRESS_ENV_NAMES",
34
+ "EGRESS_REFUSED_SOURCE",
35
+ "EgressProxy",
36
+ "Resolver",
37
+ "destination_allowed",
38
+ "host_of",
39
+ ]
40
+
41
+ EGRESS_REFUSED_SOURCE: Final[str] = "runner.egress_refused"
42
+
43
+ # Both spellings: curl and git read the lower-case ones, most language runtimes the
44
+ # upper. Reserved on a Directive's environment so a hook cannot point the Agent round it.
45
+ EGRESS_ENV_NAMES: Final[frozenset[str]] = frozenset(
46
+ {
47
+ "HTTPS_PROXY",
48
+ "HTTP_PROXY",
49
+ "ALL_PROXY",
50
+ "NO_PROXY",
51
+ "https_proxy",
52
+ "http_proxy",
53
+ "all_proxy",
54
+ "no_proxy",
55
+ }
56
+ )
57
+
58
+ # Loopback is the LLM proxy, the callback socket's neighbours and every Runner-hosted MCP
59
+ # server: all the Runner's own, none of it egress.
60
+ _LOOPBACK: Final[str] = "127.0.0.1,localhost,::1"
61
+ _PROXY_USER: Final[str] = "agentic"
62
+ _PIPE_CHUNK: Final[int] = 65536
63
+
64
+ Resolver = Callable[[str, int], Awaitable[tuple[str, int]]]
65
+
66
+
67
+ async def _system_resolver(host: str, port: int) -> tuple[str, int]:
68
+ infos = await asyncio.get_running_loop().getaddrinfo(host, port, type=0)
69
+ address = infos[0][4]
70
+ return str(address[0]), int(address[1])
71
+
72
+
73
+ def destination_allowed(allow_list: Iterable[str], host: str, port: int) -> bool:
74
+ """``host``, ``host:port`` or a ``*.domain`` pattern; a bare host admits any port."""
75
+
76
+ host = host.lower().strip("[]")
77
+ for entry in allow_list:
78
+ pattern, _, entry_port = entry.lower().partition(":")
79
+ if entry_port and entry_port != str(port):
80
+ continue
81
+ if fnmatchcase(host, pattern):
82
+ return True
83
+ return False
84
+
85
+
86
+ def host_of(url: str) -> str | None:
87
+ """The allow-list entry a URL needs: its host, with the port when it names one."""
88
+
89
+ parts = urlsplit(url)
90
+ if not parts.hostname:
91
+ return None
92
+ return parts.hostname if parts.port is None else f"{parts.hostname}:{parts.port}"
93
+
94
+
95
+ class EgressProxy:
96
+ """One attempt's forward proxy: CONNECT for TLS, absolute-form for plain HTTP."""
97
+
98
+ def __init__(
99
+ self,
100
+ allow_list: Iterable[str],
101
+ *,
102
+ token: str,
103
+ resolver: Resolver | None = None,
104
+ on_refused: Callable[[str, int], Awaitable[None]] | None = None,
105
+ host: str = "127.0.0.1",
106
+ ) -> None:
107
+ self.allow_list = tuple(dict.fromkeys(allow_list))
108
+ self._token = token
109
+ self._resolver = resolver or _system_resolver
110
+ self._on_refused = on_refused
111
+ self._host = host
112
+ self._server: asyncio.Server | None = None
113
+ self._port = 0
114
+
115
+ async def __aenter__(self) -> EgressProxy:
116
+ self._server = await asyncio.start_server(self._serve, host=self._host, port=0)
117
+ self._port = int(self._server.sockets[0].getsockname()[1])
118
+ return self
119
+
120
+ async def __aexit__(
121
+ self,
122
+ exc_type: type[BaseException] | None,
123
+ exc: BaseException | None,
124
+ traceback: TracebackType | None,
125
+ ) -> None:
126
+ if self._server is not None:
127
+ self._server.close()
128
+ with contextlib.suppress(Exception):
129
+ await self._server.wait_closed()
130
+
131
+ def env(self) -> dict[str, str]:
132
+ url = f"http://{_PROXY_USER}:{self._token}@{self._host}:{self._port}"
133
+ env = {name: url for name in EGRESS_ENV_NAMES if "no_proxy" not in name.lower()}
134
+ env.update({"NO_PROXY": _LOOPBACK, "no_proxy": _LOOPBACK})
135
+ return env
136
+
137
+ async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
138
+ try:
139
+ request = await read_request(reader)
140
+ if isinstance(request, tuple):
141
+ await self._reply(writer, request[0])
142
+ return
143
+ if not self._authorized(request):
144
+ await self._reply(writer, 407)
145
+ return
146
+ destination = _destination(request)
147
+ if destination is None:
148
+ await self._reply(writer, 400)
149
+ return
150
+ host, port = destination
151
+ if not destination_allowed(self.allow_list, host, port):
152
+ if self._on_refused is not None:
153
+ await self._on_refused(host, port)
154
+ await self._reply(writer, 403)
155
+ return
156
+ try:
157
+ address, resolved_port = await self._resolver(host, port)
158
+ upstream_reader, upstream_writer = await asyncio.open_connection(
159
+ address, resolved_port
160
+ )
161
+ except OSError:
162
+ await self._reply(writer, 502)
163
+ return
164
+ if request.method == "CONNECT":
165
+ writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
166
+ await writer.drain()
167
+ else:
168
+ upstream_writer.write(_origin_form(request))
169
+ await upstream_writer.drain()
170
+ await _pipe_both(reader, writer, upstream_reader, upstream_writer)
171
+ finally:
172
+ writer.close()
173
+ with contextlib.suppress(Exception):
174
+ await writer.wait_closed()
175
+
176
+ def _authorized(self, request: HttpRequest) -> bool:
177
+ scheme, _, encoded = request.headers.get("proxy-authorization", "").partition(" ")
178
+ if scheme.lower() != "basic":
179
+ return False
180
+ try:
181
+ _, _, offered = base64.b64decode(encoded.strip()).decode().partition(":")
182
+ except (ValueError, UnicodeDecodeError):
183
+ return False
184
+ return hmac.compare_digest(offered, self._token)
185
+
186
+ @staticmethod
187
+ async def _reply(writer: asyncio.StreamWriter, status: int) -> None:
188
+ head = response_head(status, content_type="text/plain", content_length=0)
189
+ if status == 407:
190
+ head = head.replace(
191
+ b"\r\n\r\n", b'\r\nProxy-Authenticate: Basic realm="agentic-runner"\r\n\r\n', 1
192
+ )
193
+ writer.write(head)
194
+ with contextlib.suppress(ConnectionError):
195
+ await writer.drain()
196
+
197
+
198
+ def _destination(request: HttpRequest) -> tuple[str, int] | None:
199
+ if request.method == "CONNECT":
200
+ host, _, port = request.target.rpartition(":")
201
+ return (host, int(port)) if host and port.isdigit() else None
202
+ parts = urlsplit(request.target)
203
+ if parts.scheme != "http" or not parts.hostname:
204
+ return None
205
+ return parts.hostname, parts.port or 80
206
+
207
+
208
+ def _origin_form(request: HttpRequest) -> bytes:
209
+ parts = urlsplit(request.target)
210
+ path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
211
+ headers = {
212
+ name: value
213
+ for name, value in request.headers.items()
214
+ if name not in {"proxy-authorization", "proxy-connection", "connection"}
215
+ }
216
+ head = f"{request.method} {path} HTTP/1.1\r\n" + "".join(
217
+ f"{name}: {value}\r\n" for name, value in headers.items()
218
+ )
219
+ return (head + "Connection: close\r\n\r\n").encode("latin-1") + request.body
220
+
221
+
222
+ async def _pipe_both(
223
+ client_reader: asyncio.StreamReader,
224
+ client_writer: asyncio.StreamWriter,
225
+ upstream_reader: asyncio.StreamReader,
226
+ upstream_writer: asyncio.StreamWriter,
227
+ ) -> None:
228
+ async def pipe(source: asyncio.StreamReader, sink: asyncio.StreamWriter) -> None:
229
+ with contextlib.suppress(ConnectionError, asyncio.IncompleteReadError):
230
+ while chunk := await source.read(_PIPE_CHUNK):
231
+ sink.write(chunk)
232
+ await sink.drain()
233
+ with contextlib.suppress(Exception):
234
+ sink.write_eof()
235
+
236
+ try:
237
+ await asyncio.gather(
238
+ pipe(client_reader, upstream_writer), pipe(upstream_reader, client_writer)
239
+ )
240
+ finally:
241
+ upstream_writer.close()
242
+ with contextlib.suppress(Exception):
243
+ await upstream_writer.wait_closed()