openworkproof 1.0.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.
- openworkproof/__init__.py +1 -0
- openworkproof/acceptance.py +3006 -0
- openworkproof/cli.py +164 -0
- openworkproof/composition.py +832 -0
- openworkproof/evidence.py +8281 -0
- openworkproof/execution_adapter.py +256 -0
- openworkproof/external_acceptor.py +211 -0
- openworkproof/mcp_server.py +2939 -0
- openworkproof/mcp_transport.py +72 -0
- openworkproof/models.py +3380 -0
- openworkproof/policy.py +2494 -0
- openworkproof/predicates.py +333 -0
- openworkproof/repo_pipeline/__init__.py +74 -0
- openworkproof/repo_pipeline/analysis.py +152 -0
- openworkproof/repo_pipeline/errors.py +64 -0
- openworkproof/repo_pipeline/models.py +80 -0
- openworkproof/repo_pipeline/output.py +68 -0
- openworkproof/repo_pipeline/reader.py +47 -0
- openworkproof/repo_pipeline/sources.py +120 -0
- openworkproof/repo_pipeline/traversal.py +253 -0
- openworkproof/repo_tools.py +8382 -0
- openworkproof/runtime_context.py +189 -0
- openworkproof/schema_registry.py +623 -0
- openworkproof/schemas/v0.1/acceptance-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/acceptance-rejection-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/action-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/capability-grant.schema.json +1 -0
- openworkproof/schemas/v0.1/schema-registry.json +1 -0
- openworkproof/schemas/v0.1/work-order.schema.json +1 -0
- openworkproof/signing.py +421 -0
- openworkproof/state.py +765 -0
- openworkproof/team_network_client.py +419 -0
- openworkproof/trusted_helper.py +269 -0
- openworkproof-1.0.0.dist-info/METADATA +578 -0
- openworkproof-1.0.0.dist-info/RECORD +39 -0
- openworkproof-1.0.0.dist-info/WHEEL +5 -0
- openworkproof-1.0.0.dist-info/entry_points.txt +2 -0
- openworkproof-1.0.0.dist-info/licenses/LICENSE +202 -0
- openworkproof-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Real network client for the AgentTeams execution layer.
|
|
2
|
+
|
|
3
|
+
The client implements the ``AgentTeamClient`` contract (see
|
|
4
|
+
``execution_adapter``) over a real TCP connection, so the team -> developer
|
|
5
|
+
loop works across processes and hosts, not just in-memory.
|
|
6
|
+
|
|
7
|
+
Network protocol (JSON lines over a persistent TCP session):
|
|
8
|
+
|
|
9
|
+
- ``{"action": "auth", "token": <str>}`` -> ``{"ok": true}``
|
|
10
|
+
- ``{"action": "dispatch", "task": <TeamTask>}`` -> ``{"ok": true}``
|
|
11
|
+
- ``{"action": "list-pending"}`` -> ``{"ok": true, "tasks": [...]}``
|
|
12
|
+
- ``{"action": "store-result", "result": <...>}``-> ``{"ok": true}``
|
|
13
|
+
- ``{"action": "collect"}`` -> ``{"ok": true, "results": [...]}``
|
|
14
|
+
|
|
15
|
+
Config comes from environment variables so the client deploys unchanged:
|
|
16
|
+
|
|
17
|
+
- ``OWP_TEAM_ENDPOINT`` — ``host:port`` (default ``127.0.0.1:18742``)
|
|
18
|
+
- ``OWP_TEAM_TOKEN`` — shared auth token (empty disables auth)
|
|
19
|
+
- ``OWP_TEAM_TIMEOUT`` — socket timeout seconds (default 5.0)
|
|
20
|
+
- ``OWP_TEAM_MAX_RETRIES`` — connect/request retries (default 3)
|
|
21
|
+
|
|
22
|
+
Note on the Alibaba Cloud AgentTeams SDK: a public SDK exists
|
|
23
|
+
(``@alicloud/agentteams20260605``) but currently ships only TypeScript/Java/
|
|
24
|
+
Swift/PHP, and its API surface is the governance/management plane
|
|
25
|
+
(workspaces, identity, policy) rather than task dispatch/recovery. Until a
|
|
26
|
+
Python SDK with an execution-plane API is published, this module provides the
|
|
27
|
+
real network transport for the task lifecycle over a documented protocol; the
|
|
28
|
+
adapter boundary in ``execution_adapter.py`` is where a future SDK would plug
|
|
29
|
+
in without changing the business layer.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
import socket
|
|
39
|
+
import threading
|
|
40
|
+
import time
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from openworkproof.execution_adapter import (
|
|
44
|
+
TaskState,
|
|
45
|
+
TeamTask,
|
|
46
|
+
TeamTaskResult,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_logger = logging.getLogger("openworkproof.team_network_client")
|
|
50
|
+
|
|
51
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
52
|
+
DEFAULT_PORT = 18742
|
|
53
|
+
DEFAULT_TIMEOUT = 5.0
|
|
54
|
+
DEFAULT_MAX_RETRIES = 3
|
|
55
|
+
DEFAULT_BACKOFF = 0.2
|
|
56
|
+
|
|
57
|
+
_ACTION_PATHS = frozenset(
|
|
58
|
+
{"auth", "dispatch", "list-pending", "store-result", "collect"}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TeamNetworkError(Exception):
|
|
63
|
+
"""Base class for network-layer team client failures."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TeamAuthenticationError(TeamNetworkError):
|
|
67
|
+
"""The remote team service rejected the auth token."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TeamTimeoutError(TeamNetworkError):
|
|
71
|
+
"""A request exceeded the configured timeout."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TeamProtocolError(TeamNetworkError):
|
|
75
|
+
"""The remote service returned a malformed or refused response."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TeamNetworkConfig:
|
|
79
|
+
"""Resolved client configuration (env-overridable)."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
host: str = DEFAULT_HOST,
|
|
85
|
+
port: int = DEFAULT_PORT,
|
|
86
|
+
token: str = "",
|
|
87
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
88
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
89
|
+
backoff: float = DEFAULT_BACKOFF,
|
|
90
|
+
) -> None:
|
|
91
|
+
if not isinstance(host, str) or not host:
|
|
92
|
+
raise TeamNetworkError("team endpoint host is invalid")
|
|
93
|
+
if type(port) is not int or not 1 <= port <= 65535:
|
|
94
|
+
raise TeamNetworkError("team endpoint port is invalid")
|
|
95
|
+
if type(timeout) not in {int, float} or timeout <= 0:
|
|
96
|
+
raise TeamNetworkError("team timeout is invalid")
|
|
97
|
+
if type(max_retries) is not int or max_retries < 0:
|
|
98
|
+
raise TeamNetworkError("team max retries is invalid")
|
|
99
|
+
if type(backoff) not in {int, float} or backoff < 0:
|
|
100
|
+
raise TeamNetworkError("team backoff is invalid")
|
|
101
|
+
self.host = host
|
|
102
|
+
self.port = port
|
|
103
|
+
self.token = token
|
|
104
|
+
self.timeout = timeout
|
|
105
|
+
self.max_retries = max_retries
|
|
106
|
+
self.backoff = backoff
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def from_env(cls) -> "TeamNetworkConfig":
|
|
110
|
+
endpoint = os.environ.get("OWP_TEAM_ENDPOINT")
|
|
111
|
+
host, port = DEFAULT_HOST, DEFAULT_PORT
|
|
112
|
+
if endpoint:
|
|
113
|
+
parts = endpoint.rsplit(":", 1)
|
|
114
|
+
if len(parts) == 2 and parts[1].isdigit():
|
|
115
|
+
host, port = parts[0], int(parts[1])
|
|
116
|
+
return cls(
|
|
117
|
+
host=host,
|
|
118
|
+
port=port,
|
|
119
|
+
token=os.environ.get("OWP_TEAM_TOKEN", ""),
|
|
120
|
+
timeout=float(os.environ.get("OWP_TEAM_TIMEOUT", str(DEFAULT_TIMEOUT))),
|
|
121
|
+
max_retries=int(
|
|
122
|
+
os.environ.get("OWP_TEAM_MAX_RETRIES", str(DEFAULT_MAX_RETRIES))
|
|
123
|
+
),
|
|
124
|
+
backoff=float(os.environ.get("OWP_TEAM_BACKOFF", str(DEFAULT_BACKOFF))),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TeamNetworkClient:
|
|
129
|
+
"""Real TCP client implementing the AgentTeamClient contract."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, config: TeamNetworkConfig | None = None) -> None:
|
|
132
|
+
self._config = config or TeamNetworkConfig.from_env()
|
|
133
|
+
self._conn: socket.socket | None = None
|
|
134
|
+
self._lock = threading.Lock()
|
|
135
|
+
|
|
136
|
+
# ---- lifecycle ---------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def connect(self) -> None:
|
|
139
|
+
"""Open the TCP session and perform the auth handshake."""
|
|
140
|
+
self._ensure_connected()
|
|
141
|
+
if self._config.token:
|
|
142
|
+
self._request({"action": "auth", "token": self._config.token}, authed=False)
|
|
143
|
+
|
|
144
|
+
def disconnect(self) -> None:
|
|
145
|
+
with self._lock:
|
|
146
|
+
if self._conn is not None:
|
|
147
|
+
try:
|
|
148
|
+
self._conn.close()
|
|
149
|
+
except OSError:
|
|
150
|
+
pass
|
|
151
|
+
self._conn = None
|
|
152
|
+
|
|
153
|
+
# ---- AgentTeamClient contract -------------------------------------------
|
|
154
|
+
|
|
155
|
+
def dispatch(self, task: TeamTask) -> None:
|
|
156
|
+
self._with_retry(
|
|
157
|
+
lambda: self._request(
|
|
158
|
+
{"action": "dispatch", "task": _task_to_dict(task)}
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def list_pending(self) -> tuple[TeamTask, ...]:
|
|
163
|
+
response = self._with_retry(
|
|
164
|
+
lambda: self._request({"action": "list-pending"})
|
|
165
|
+
)
|
|
166
|
+
return tuple(_task_from_dict(item) for item in response["tasks"])
|
|
167
|
+
|
|
168
|
+
def store_result(self, result: TeamTaskResult) -> None:
|
|
169
|
+
self._with_retry(
|
|
170
|
+
lambda: self._request(
|
|
171
|
+
{"action": "store-result", "result": _result_to_dict(result)}
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def collect(self) -> tuple[TeamTaskResult, ...]:
|
|
176
|
+
response = self._with_retry(lambda: self._request({"action": "collect"}))
|
|
177
|
+
return tuple(_result_from_dict(item) for item in response["results"])
|
|
178
|
+
|
|
179
|
+
# ---- internals ----------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def _ensure_connected(self) -> socket.socket:
|
|
182
|
+
with self._lock:
|
|
183
|
+
if self._conn is not None:
|
|
184
|
+
return self._conn
|
|
185
|
+
_logger.info(
|
|
186
|
+
"connecting to team service %s:%s",
|
|
187
|
+
self._config.host,
|
|
188
|
+
self._config.port,
|
|
189
|
+
)
|
|
190
|
+
try:
|
|
191
|
+
conn = socket.create_connection(
|
|
192
|
+
(self._config.host, self._config.port),
|
|
193
|
+
timeout=self._config.timeout,
|
|
194
|
+
)
|
|
195
|
+
except OSError as error:
|
|
196
|
+
raise TeamNetworkError(
|
|
197
|
+
f"cannot connect to team service: {error}"
|
|
198
|
+
) from error
|
|
199
|
+
conn.settimeout(self._config.timeout)
|
|
200
|
+
self._conn = conn
|
|
201
|
+
return conn
|
|
202
|
+
|
|
203
|
+
def _request(
|
|
204
|
+
self, request: dict[str, Any], *, authed: bool = True
|
|
205
|
+
) -> dict[str, Any]:
|
|
206
|
+
conn = self._ensure_connected()
|
|
207
|
+
try:
|
|
208
|
+
conn.sendall((json.dumps(request) + "\n").encode("utf-8"))
|
|
209
|
+
data = conn.recv(65536)
|
|
210
|
+
except socket.timeout as error:
|
|
211
|
+
self.disconnect()
|
|
212
|
+
raise TeamTimeoutError("team request timed out") from error
|
|
213
|
+
except OSError as error:
|
|
214
|
+
self.disconnect()
|
|
215
|
+
raise TeamNetworkError(f"team request failed: {error}") from error
|
|
216
|
+
if not data:
|
|
217
|
+
self.disconnect()
|
|
218
|
+
raise TeamNetworkError("team service closed the connection")
|
|
219
|
+
try:
|
|
220
|
+
response = json.loads(data.decode("utf-8"))
|
|
221
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
222
|
+
self.disconnect()
|
|
223
|
+
raise TeamProtocolError("team response is not JSON") from error
|
|
224
|
+
if authed and not response.get("ok"):
|
|
225
|
+
raise TeamProtocolError(response.get("error", "team refused the request"))
|
|
226
|
+
return response
|
|
227
|
+
|
|
228
|
+
def _with_retry(self, operation):
|
|
229
|
+
last_error: Exception | None = None
|
|
230
|
+
for attempt in range(self._config.max_retries + 1):
|
|
231
|
+
try:
|
|
232
|
+
return operation()
|
|
233
|
+
except (TeamNetworkError, TeamTimeoutError) as error:
|
|
234
|
+
last_error = error
|
|
235
|
+
if attempt < self._config.max_retries:
|
|
236
|
+
self.disconnect()
|
|
237
|
+
delay = self._config.backoff * (2**attempt)
|
|
238
|
+
_logger.warning(
|
|
239
|
+
"team request attempt %d/%d failed: %s; retrying in %.2fs",
|
|
240
|
+
attempt + 1,
|
|
241
|
+
self._config.max_retries + 1,
|
|
242
|
+
error,
|
|
243
|
+
delay,
|
|
244
|
+
)
|
|
245
|
+
time.sleep(delay)
|
|
246
|
+
assert last_error is not None
|
|
247
|
+
raise last_error
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _task_to_dict(task: TeamTask) -> dict[str, Any]:
|
|
251
|
+
return {
|
|
252
|
+
"task_id": task.task_id,
|
|
253
|
+
"kind": task.kind,
|
|
254
|
+
"ledger": task.ledger,
|
|
255
|
+
"payload": task.payload,
|
|
256
|
+
"state": task.state.value,
|
|
257
|
+
"created_at": task.created_at,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _task_from_dict(data: dict[str, Any]) -> TeamTask:
|
|
262
|
+
return TeamTask(
|
|
263
|
+
task_id=data["task_id"],
|
|
264
|
+
kind=data["kind"],
|
|
265
|
+
ledger=data["ledger"],
|
|
266
|
+
payload=data.get("payload", {}),
|
|
267
|
+
state=TaskState(data["state"]),
|
|
268
|
+
created_at=data.get("created_at", ""),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _result_to_dict(result: TeamTaskResult) -> dict[str, Any]:
|
|
273
|
+
return {
|
|
274
|
+
"task_id": result.task_id,
|
|
275
|
+
"kind": result.kind,
|
|
276
|
+
"status": result.status.value,
|
|
277
|
+
"result": result.result,
|
|
278
|
+
"error": result.error,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _result_from_dict(data: dict[str, Any]) -> TeamTaskResult:
|
|
283
|
+
return TeamTaskResult(
|
|
284
|
+
task_id=data["task_id"],
|
|
285
|
+
kind=data["kind"],
|
|
286
|
+
status=TaskState(data["status"]),
|
|
287
|
+
result=data.get("result", {}),
|
|
288
|
+
error=data.get("error"),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class TeamNetworkService:
|
|
293
|
+
"""Reference TCP team service implementing the documented protocol."""
|
|
294
|
+
|
|
295
|
+
def __init__(
|
|
296
|
+
self,
|
|
297
|
+
*,
|
|
298
|
+
host: str = DEFAULT_HOST,
|
|
299
|
+
port: int = DEFAULT_PORT,
|
|
300
|
+
token: str = "",
|
|
301
|
+
) -> None:
|
|
302
|
+
self._host = host
|
|
303
|
+
self._port = port
|
|
304
|
+
self._token = token
|
|
305
|
+
self._queue: list[TeamTask] = []
|
|
306
|
+
self._results: dict[str, TeamTaskResult] = {}
|
|
307
|
+
self._socket: socket.socket | None = None
|
|
308
|
+
self._thread: threading.Thread | None = None
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def port(self) -> int:
|
|
312
|
+
return self._port
|
|
313
|
+
|
|
314
|
+
def start(self) -> None:
|
|
315
|
+
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
316
|
+
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
317
|
+
self._socket.bind((self._host, self._port))
|
|
318
|
+
self._socket.listen(4)
|
|
319
|
+
self._thread = threading.Thread(target=self._serve, daemon=True)
|
|
320
|
+
self._thread.start()
|
|
321
|
+
|
|
322
|
+
def stop(self) -> None:
|
|
323
|
+
if self._socket is not None:
|
|
324
|
+
try:
|
|
325
|
+
self._socket.close()
|
|
326
|
+
except OSError:
|
|
327
|
+
pass
|
|
328
|
+
self._socket = None
|
|
329
|
+
|
|
330
|
+
def _serve(self) -> None:
|
|
331
|
+
assert self._socket is not None
|
|
332
|
+
while True:
|
|
333
|
+
try:
|
|
334
|
+
conn, _ = self._socket.accept()
|
|
335
|
+
except OSError:
|
|
336
|
+
return
|
|
337
|
+
try:
|
|
338
|
+
self._handle(conn)
|
|
339
|
+
finally:
|
|
340
|
+
try:
|
|
341
|
+
conn.close()
|
|
342
|
+
except OSError:
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
def _handle(self, conn: socket.socket) -> None:
|
|
346
|
+
authed = not self._token
|
|
347
|
+
while True:
|
|
348
|
+
try:
|
|
349
|
+
data = conn.recv(65536)
|
|
350
|
+
except OSError:
|
|
351
|
+
return
|
|
352
|
+
if not data:
|
|
353
|
+
return
|
|
354
|
+
for line in data.decode("utf-8").splitlines():
|
|
355
|
+
if not line:
|
|
356
|
+
continue
|
|
357
|
+
try:
|
|
358
|
+
request = json.loads(line)
|
|
359
|
+
except ValueError:
|
|
360
|
+
self._send(conn, {"ok": False, "error": "bad json"})
|
|
361
|
+
continue
|
|
362
|
+
if request.get("action") == "auth":
|
|
363
|
+
authed = request.get("token") == self._token
|
|
364
|
+
self._send(conn, {"ok": authed})
|
|
365
|
+
continue
|
|
366
|
+
if not authed:
|
|
367
|
+
self._send(conn, {"ok": False, "error": "not authenticated"})
|
|
368
|
+
continue
|
|
369
|
+
self._send(conn, self._dispatch(request))
|
|
370
|
+
|
|
371
|
+
def _dispatch(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
372
|
+
action = request.get("action")
|
|
373
|
+
if action == "dispatch":
|
|
374
|
+
task = _task_from_dict(request["task"])
|
|
375
|
+
self._queue.append(task)
|
|
376
|
+
_logger.debug("service dispatched task %s", task.task_id)
|
|
377
|
+
return {"ok": True}
|
|
378
|
+
if action == "list-pending":
|
|
379
|
+
return {
|
|
380
|
+
"ok": True,
|
|
381
|
+
"tasks": [_task_to_dict(task) for task in self._queue],
|
|
382
|
+
}
|
|
383
|
+
if action == "store-result":
|
|
384
|
+
result = _result_from_dict(request["result"])
|
|
385
|
+
self._results[result.task_id] = result
|
|
386
|
+
return {"ok": True}
|
|
387
|
+
if action == "collect":
|
|
388
|
+
results = tuple(self._results.values())
|
|
389
|
+
self._results.clear()
|
|
390
|
+
return {
|
|
391
|
+
"ok": True,
|
|
392
|
+
"results": [_result_to_dict(item) for item in results],
|
|
393
|
+
}
|
|
394
|
+
return {"ok": False, "error": f"unknown action: {action}"}
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
def _send(conn: socket.socket, payload: dict[str, Any]) -> None:
|
|
398
|
+
try:
|
|
399
|
+
conn.sendall((json.dumps(payload) + "\n").encode("utf-8"))
|
|
400
|
+
except OSError:
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def main() -> int:
|
|
405
|
+
"""Subprocess entry: ``python -m openworkproof.team_network_client``."""
|
|
406
|
+
endpoint = os.environ.get("OWP_TEAM_ENDPOINT", f"{DEFAULT_HOST}:{DEFAULT_PORT}")
|
|
407
|
+
host, port = endpoint.rsplit(":", 1)
|
|
408
|
+
service = TeamNetworkService(
|
|
409
|
+
host=host,
|
|
410
|
+
port=int(port),
|
|
411
|
+
token=os.environ.get("OWP_TEAM_TOKEN", ""),
|
|
412
|
+
)
|
|
413
|
+
service.start()
|
|
414
|
+
threading.Event().wait()
|
|
415
|
+
return 0
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
if __name__ == "__main__":
|
|
419
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Fixed trusted-helper request dispatcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from typing import BinaryIO, Sequence
|
|
12
|
+
|
|
13
|
+
import rfc8785
|
|
14
|
+
|
|
15
|
+
from openworkproof import repo_tools
|
|
16
|
+
from openworkproof.models import RepoReadOutput
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
REQUEST_SCHEMA = "openworkproof-trusted-helper-request/0.1"
|
|
20
|
+
RESPONSE_SCHEMA = "openworkproof-trusted-helper-response/0.1"
|
|
21
|
+
MAX_REQUEST_BYTES = 8_192
|
|
22
|
+
RUNTIME_ROOT = Path("/runtime")
|
|
23
|
+
EXIT_BY_CODE = {
|
|
24
|
+
"REQUEST_INVALID": 64,
|
|
25
|
+
"RECOVERY_REQUIRED": 65,
|
|
26
|
+
"PATH_DENIED": 66,
|
|
27
|
+
"FILE_CHANGED": 67,
|
|
28
|
+
"INTERNAL_ERROR": 70,
|
|
29
|
+
}
|
|
30
|
+
_INTERNAL_ERROR_BYTES = (
|
|
31
|
+
b'{"code":"INTERNAL_ERROR","schema_version":'
|
|
32
|
+
b'"openworkproof-trusted-helper-response/0.1","status":"error"}'
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_REQUEST_KEYS = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"schema_version",
|
|
38
|
+
"operation",
|
|
39
|
+
"workspace_id",
|
|
40
|
+
"source_artifact_sha256",
|
|
41
|
+
"expected_head_commit",
|
|
42
|
+
"expected_workspace_manifest_digest",
|
|
43
|
+
"path",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
_CANDIDATE_ERROR_CODES = (
|
|
47
|
+
"RECOVERY_REQUIRED",
|
|
48
|
+
"PATH_DENIED",
|
|
49
|
+
"FILE_CHANGED",
|
|
50
|
+
)
|
|
51
|
+
_DIGEST_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
52
|
+
_COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
|
53
|
+
_RESULT_KEYS = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"path",
|
|
56
|
+
"content_sha256",
|
|
57
|
+
"size_bytes",
|
|
58
|
+
"workspace_manifest_digest",
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _RequestInvalid(ValueError):
|
|
64
|
+
"""The request is not the frozen canonical dispatcher representation."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _object_without_duplicate_keys(
|
|
68
|
+
pairs: list[tuple[str, object]],
|
|
69
|
+
) -> dict[str, object]:
|
|
70
|
+
value: dict[str, object] = {}
|
|
71
|
+
for key, item in pairs:
|
|
72
|
+
if key in value:
|
|
73
|
+
raise _RequestInvalid
|
|
74
|
+
value[key] = item
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _reject_json_number(unused: str) -> None:
|
|
79
|
+
raise _RequestInvalid
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_request(raw: bytes, runtime_root: Path) -> repo_tools.CandidateReadRequest:
|
|
83
|
+
if type(raw) is not bytes or not 1 <= len(raw) <= MAX_REQUEST_BYTES:
|
|
84
|
+
raise _RequestInvalid
|
|
85
|
+
try:
|
|
86
|
+
value = json.loads(
|
|
87
|
+
raw,
|
|
88
|
+
object_pairs_hook=_object_without_duplicate_keys,
|
|
89
|
+
parse_float=_reject_json_number,
|
|
90
|
+
parse_int=_reject_json_number,
|
|
91
|
+
parse_constant=_reject_json_number,
|
|
92
|
+
)
|
|
93
|
+
canonical = rfc8785.dumps(value)
|
|
94
|
+
if type(canonical) is not bytes:
|
|
95
|
+
raise TypeError("canonicalizer did not return bytes")
|
|
96
|
+
if canonical != raw:
|
|
97
|
+
raise _RequestInvalid
|
|
98
|
+
except (
|
|
99
|
+
_RequestInvalid,
|
|
100
|
+
json.JSONDecodeError,
|
|
101
|
+
UnicodeDecodeError,
|
|
102
|
+
RecursionError,
|
|
103
|
+
rfc8785.CanonicalizationError,
|
|
104
|
+
):
|
|
105
|
+
raise _RequestInvalid from None
|
|
106
|
+
if type(value) is not dict or frozenset(value) != _REQUEST_KEYS:
|
|
107
|
+
raise _RequestInvalid
|
|
108
|
+
if any(type(value[key]) is not str for key in _REQUEST_KEYS):
|
|
109
|
+
raise _RequestInvalid
|
|
110
|
+
if (
|
|
111
|
+
value["schema_version"] != REQUEST_SCHEMA
|
|
112
|
+
or value["operation"] != "repo_read"
|
|
113
|
+
or _DIGEST_PATTERN.fullmatch(value["workspace_id"]) is None
|
|
114
|
+
or _DIGEST_PATTERN.fullmatch(value["source_artifact_sha256"]) is None
|
|
115
|
+
or _COMMIT_PATTERN.fullmatch(value["expected_head_commit"]) is None
|
|
116
|
+
or _DIGEST_PATTERN.fullmatch(
|
|
117
|
+
value["expected_workspace_manifest_digest"]
|
|
118
|
+
)
|
|
119
|
+
is None
|
|
120
|
+
):
|
|
121
|
+
raise _RequestInvalid
|
|
122
|
+
try:
|
|
123
|
+
repo_tools.validate_canonical_relative_path(value["path"])
|
|
124
|
+
except repo_tools.PathError:
|
|
125
|
+
raise _RequestInvalid from None
|
|
126
|
+
return repo_tools.CandidateReadRequest(
|
|
127
|
+
runtime_root=runtime_root,
|
|
128
|
+
workspace_id=value["workspace_id"],
|
|
129
|
+
source_artifact_sha256=value["source_artifact_sha256"],
|
|
130
|
+
expected_head_commit=value["expected_head_commit"],
|
|
131
|
+
expected_workspace_manifest_digest=(
|
|
132
|
+
value["expected_workspace_manifest_digest"]
|
|
133
|
+
),
|
|
134
|
+
path=value["path"],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _error_response(code: str) -> dict[str, str]:
|
|
139
|
+
return {
|
|
140
|
+
"schema_version": RESPONSE_SCHEMA,
|
|
141
|
+
"status": "error",
|
|
142
|
+
"code": code,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _closed_candidate_error_code(
|
|
147
|
+
error: repo_tools.CandidateReadError,
|
|
148
|
+
) -> str:
|
|
149
|
+
try:
|
|
150
|
+
code = error.code
|
|
151
|
+
except BaseException:
|
|
152
|
+
return "INTERNAL_ERROR"
|
|
153
|
+
if type(code) is str and code in _CANDIDATE_ERROR_CODES:
|
|
154
|
+
return code
|
|
155
|
+
return "INTERNAL_ERROR"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _exit_for_code(code: str) -> int:
|
|
159
|
+
if code == "REQUEST_INVALID":
|
|
160
|
+
return 64
|
|
161
|
+
if code == "RECOVERY_REQUIRED":
|
|
162
|
+
return 65
|
|
163
|
+
if code == "PATH_DENIED":
|
|
164
|
+
return 66
|
|
165
|
+
if code == "FILE_CHANGED":
|
|
166
|
+
return 67
|
|
167
|
+
return 70
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _validated_success_response(
|
|
171
|
+
result: object,
|
|
172
|
+
request: repo_tools.CandidateReadRequest,
|
|
173
|
+
) -> dict[str, object]:
|
|
174
|
+
if type(result) is not repo_tools.CandidateReadResult:
|
|
175
|
+
raise TypeError("candidate read result is not closed")
|
|
176
|
+
if type(result.content) is not bytes:
|
|
177
|
+
raise TypeError("candidate read content is not closed")
|
|
178
|
+
if type(result.output) is not RepoReadOutput:
|
|
179
|
+
raise TypeError("candidate read output is not closed")
|
|
180
|
+
content_sha256 = hashlib.sha256(result.content).hexdigest()
|
|
181
|
+
if (
|
|
182
|
+
result.output.path != request.path
|
|
183
|
+
or result.output.workspace_manifest_digest
|
|
184
|
+
!= request.expected_workspace_manifest_digest
|
|
185
|
+
or result.output.size_bytes != len(result.content)
|
|
186
|
+
or result.output.content_sha256 != content_sha256
|
|
187
|
+
):
|
|
188
|
+
raise ValueError("candidate read result binding mismatch")
|
|
189
|
+
dumped = result.output.model_dump(mode="json")
|
|
190
|
+
if type(dumped) is not dict or frozenset(dumped) != _RESULT_KEYS:
|
|
191
|
+
raise TypeError("candidate read output dump is not closed")
|
|
192
|
+
if (
|
|
193
|
+
type(dumped["path"]) is not str
|
|
194
|
+
or dumped["path"] != request.path
|
|
195
|
+
or type(dumped["content_sha256"]) is not str
|
|
196
|
+
or dumped["content_sha256"] != content_sha256
|
|
197
|
+
or type(dumped["size_bytes"]) is not int
|
|
198
|
+
or dumped["size_bytes"] != len(result.content)
|
|
199
|
+
or type(dumped["workspace_manifest_digest"]) is not str
|
|
200
|
+
or dumped["workspace_manifest_digest"]
|
|
201
|
+
!= request.expected_workspace_manifest_digest
|
|
202
|
+
):
|
|
203
|
+
raise ValueError("candidate read output dump binding mismatch")
|
|
204
|
+
return {
|
|
205
|
+
"schema_version": RESPONSE_SCHEMA,
|
|
206
|
+
"status": "ok",
|
|
207
|
+
"result": dumped,
|
|
208
|
+
"content_b64url": base64.urlsafe_b64encode(result.content)
|
|
209
|
+
.decode("ascii")
|
|
210
|
+
.rstrip("="),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _write_response(
|
|
215
|
+
stdout: BinaryIO,
|
|
216
|
+
response: dict[str, object],
|
|
217
|
+
exit_code: int,
|
|
218
|
+
) -> int:
|
|
219
|
+
try:
|
|
220
|
+
encoded = rfc8785.dumps(response)
|
|
221
|
+
if type(encoded) is not bytes:
|
|
222
|
+
raise TypeError("response canonicalizer did not return bytes")
|
|
223
|
+
except BaseException:
|
|
224
|
+
encoded = _INTERNAL_ERROR_BYTES
|
|
225
|
+
exit_code = 70
|
|
226
|
+
try:
|
|
227
|
+
if stdout.write(encoded) != len(encoded):
|
|
228
|
+
return 70
|
|
229
|
+
except BaseException:
|
|
230
|
+
return 70
|
|
231
|
+
return exit_code
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main(
|
|
235
|
+
argv: Sequence[str],
|
|
236
|
+
stdin: BinaryIO,
|
|
237
|
+
stdout: BinaryIO,
|
|
238
|
+
runtime_root: Path,
|
|
239
|
+
) -> int:
|
|
240
|
+
try:
|
|
241
|
+
invalid_argv = (
|
|
242
|
+
not isinstance(argv, Sequence)
|
|
243
|
+
or isinstance(argv, (str, bytes, bytearray))
|
|
244
|
+
or len(argv) != 0
|
|
245
|
+
)
|
|
246
|
+
if invalid_argv:
|
|
247
|
+
return _write_response(
|
|
248
|
+
stdout,
|
|
249
|
+
_error_response("REQUEST_INVALID"),
|
|
250
|
+
64,
|
|
251
|
+
)
|
|
252
|
+
raw = stdin.read(MAX_REQUEST_BYTES + 1)
|
|
253
|
+
request = _parse_request(raw, runtime_root)
|
|
254
|
+
result = repo_tools.read_candidate_file(request)
|
|
255
|
+
response = _validated_success_response(result, request)
|
|
256
|
+
return _write_response(stdout, response, 0)
|
|
257
|
+
except _RequestInvalid:
|
|
258
|
+
code = "REQUEST_INVALID"
|
|
259
|
+
except repo_tools.CandidateReadError as error:
|
|
260
|
+
code = _closed_candidate_error_code(error)
|
|
261
|
+
except BaseException:
|
|
262
|
+
code = "INTERNAL_ERROR"
|
|
263
|
+
return _write_response(stdout, _error_response(code), _exit_for_code(code))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
raise SystemExit(
|
|
268
|
+
main(sys.argv[1:], sys.stdin.buffer, sys.stdout.buffer, RUNTIME_ROOT)
|
|
269
|
+
)
|