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.
- agentic_runner/__init__.py +12 -0
- agentic_runner/activities.py +4918 -0
- agentic_runner/callback.py +342 -0
- agentic_runner/child_watcher.py +66 -0
- agentic_runner/cli.py +416 -0
- agentic_runner/config.py +105 -0
- agentic_runner/credentials.py +252 -0
- agentic_runner/device_login_activities.py +79 -0
- agentic_runner/egress.py +243 -0
- agentic_runner/heartbeat_link.py +249 -0
- agentic_runner/hooks.py +455 -0
- agentic_runner/host_store.py +295 -0
- agentic_runner/integrations/__init__.py +0 -0
- agentic_runner/integrations/git/__init__.py +1 -0
- agentic_runner/integrations/git/contracts.py +198 -0
- agentic_runner/integrations/git/evidence.py +442 -0
- agentic_runner/integrations/git/fake_workspace.py +339 -0
- agentic_runner/integrations/git/workspace.py +921 -0
- agentic_runner/integrations/github/__init__.py +53 -0
- agentic_runner/integrations/github/auth.py +171 -0
- agentic_runner/integrations/github/fake_client.py +494 -0
- agentic_runner/integrations/github/gh_client.py +944 -0
- agentic_runner/lifecycle.py +48 -0
- agentic_runner/llm_proxy.py +937 -0
- agentic_runner/mcp.py +342 -0
- agentic_runner/message_store.py +341 -0
- agentic_runner/py.typed +0 -0
- agentic_runner/recipient_key_secret.py +134 -0
- agentic_runner/registration.py +363 -0
- agentic_runner/runtime/__init__.py +0 -0
- agentic_runner/runtime/verifier_command.py +344 -0
- agentic_runner/sealed_box.py +509 -0
- agentic_runner/service.py +1068 -0
- agentic_runner/tiny_http.py +133 -0
- agentic_runner/triage_activities.py +113 -0
- agentic_runner/user_sources.py +546 -0
- agentic_runner/workers/__init__.py +1 -0
- agentic_runner/workers/_runtime_support.py +388 -0
- agentic_runner/workers/agent_runtime.py +93 -0
- agentic_runner/workers/claude_runtime.py +226 -0
- agentic_runner/workers/codex_runtime.py +311 -0
- agentic_runner/workers/command_policy.py +250 -0
- agentic_runner/workers/contract_device_login.py +211 -0
- agentic_runner/workers/contract_isolation.py +500 -0
- agentic_runner/workers/fastapi_client.py +396 -0
- agentic_runner/workers/harness_usage.py +65 -0
- agentic_runner/workers/mcp_config.py +111 -0
- agentic_runner/workers/settings.py +314 -0
- agentic_runner/workstation.py +687 -0
- agentic_runner-2.6.0.dist-info/METADATA +49 -0
- agentic_runner-2.6.0.dist-info/RECORD +54 -0
- agentic_runner-2.6.0.dist-info/WHEEL +4 -0
- agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
agentic_runner/egress.py
ADDED
|
@@ -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()
|