offwork 0.4.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 (42) hide show
  1. offwork/__init__.py +167 -0
  2. offwork/__main__.py +770 -0
  3. offwork/_venv.py +174 -0
  4. offwork/core/__init__.py +15 -0
  5. offwork/core/errors.py +83 -0
  6. offwork/core/models.py +174 -0
  7. offwork/core/pairing.py +389 -0
  8. offwork/core/progress.py +91 -0
  9. offwork/core/signing.py +91 -0
  10. offwork/core/task.py +520 -0
  11. offwork/core/token.py +184 -0
  12. offwork/core/version.py +10 -0
  13. offwork/graph/__init__.py +5 -0
  14. offwork/graph/analyzer.py +637 -0
  15. offwork/graph/decorator.py +87 -0
  16. offwork/graph/graph.py +995 -0
  17. offwork/graph/store.py +500 -0
  18. offwork/graph/tracing.py +429 -0
  19. offwork/py.typed +0 -0
  20. offwork/typing.py +48 -0
  21. offwork/worker/__init__.py +18 -0
  22. offwork/worker/backends/__init__.py +3 -0
  23. offwork/worker/backends/base.py +149 -0
  24. offwork/worker/backends/http.py +237 -0
  25. offwork/worker/backends/local.py +452 -0
  26. offwork/worker/backends/rabbitmq.py +410 -0
  27. offwork/worker/backends/redis.py +175 -0
  28. offwork/worker/deps.py +365 -0
  29. offwork/worker/remote.py +793 -0
  30. offwork/worker/result.py +276 -0
  31. offwork/worker/sandbox/Dockerfile +24 -0
  32. offwork/worker/sandbox/__init__.py +18 -0
  33. offwork/worker/sandbox/_protocol.py +50 -0
  34. offwork/worker/sandbox/docker.py +438 -0
  35. offwork/worker/sandbox/guest_agent.py +622 -0
  36. offwork/worker/schedule.py +26 -0
  37. offwork/worker/worker.py +263 -0
  38. offwork-0.4.0.dist-info/METADATA +143 -0
  39. offwork-0.4.0.dist-info/RECORD +42 -0
  40. offwork-0.4.0.dist-info/WHEEL +4 -0
  41. offwork-0.4.0.dist-info/entry_points.txt +3 -0
  42. offwork-0.4.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,389 @@
1
+ """PIN-based pairing protocol for client-worker key exchange.
2
+
3
+ Pairing allows a client and worker to establish a shared secret by
4
+ entering the same short PIN on both sides. The protocol is inspired by
5
+ SPAKE2 / SAS-based verification and works as follows:
6
+
7
+ 1. One side (the *initiator*) generates a random 6-digit PIN and shows it
8
+ to the user.
9
+ 2. The user enters the same PIN on the other side (the *responder*).
10
+ 3. Both sides independently derive an *intermediate secret* from the PIN
11
+ using a key-derivation step (HMAC-SHA256 with a fixed salt).
12
+ 4. The initiator generates a random *challenge nonce* and sends it to the
13
+ responder over the backend channel.
14
+ 5. The responder computes ``HMAC(intermediate_secret, nonce)`` and sends
15
+ the result back.
16
+ 6. The initiator verifies the response. If it matches, both sides derive
17
+ the final shared secret from
18
+ ``HMAC(intermediate_secret, nonce ‖ "confirmed")``.
19
+
20
+ The protocol prevents replay attacks (nonce is random per session) and
21
+ ensures that a passive eavesdropper who observes the nonce+response
22
+ cannot recover the PIN or the shared secret.
23
+
24
+ All primitives are stdlib-only.
25
+ """
26
+
27
+ import os
28
+ import hmac
29
+ import json
30
+ import time
31
+ import asyncio
32
+ import hashlib
33
+ import logging
34
+ import secrets
35
+ from typing import Any
36
+ from pathlib import Path
37
+ from dataclasses import field, dataclass
38
+
39
+ from offwork.core.errors import PairingError
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # Fixed salt used to bind the PIN derivation to offwork
44
+ _PIN_SALT = b"offwork-pairing-v1"
45
+
46
+ # Where key material is persisted
47
+ _DEFAULT_KEY_DIR = Path.home() / ".offwork"
48
+ _CLIENT_KEY_FILE = "client.key"
49
+ _WORKER_KEY_FILE = "worker.key"
50
+
51
+ # PIN length (digits)
52
+ PIN_LENGTH = 6
53
+
54
+
55
+ def generate_pin() -> str:
56
+ """Generate a random numeric PIN of :data:`PIN_LENGTH` digits."""
57
+ # secrets.randbelow is cryptographically secure
58
+ num = secrets.randbelow(10 ** PIN_LENGTH)
59
+ return str(num).zfill(PIN_LENGTH)
60
+
61
+
62
+ def _derive_intermediate(pin: str) -> bytes:
63
+ """Derive a 32-byte intermediate key from a PIN string."""
64
+ return hmac.new(
65
+ _PIN_SALT, pin.encode("utf-8"), hashlib.sha256
66
+ ).digest()
67
+
68
+
69
+ def generate_challenge() -> bytes:
70
+ """Generate a 32-byte random challenge nonce."""
71
+ return os.urandom(32)
72
+
73
+
74
+ def compute_response(intermediate: bytes, challenge: bytes) -> bytes:
75
+ """Compute the challenge-response: ``HMAC(intermediate, challenge)``."""
76
+ return hmac.new(intermediate, challenge, hashlib.sha256).digest()
77
+
78
+
79
+ def verify_response(
80
+ intermediate: bytes, challenge: bytes, response: bytes
81
+ ) -> bool:
82
+ """Verify a challenge-response in constant time."""
83
+ expected = compute_response(intermediate, challenge)
84
+ return hmac.compare_digest(expected, response)
85
+
86
+
87
+ def derive_shared_secret(intermediate: bytes, challenge: bytes) -> bytes:
88
+ """Derive the final 32-byte shared secret after successful verification."""
89
+ material = challenge + b"confirmed"
90
+ return hmac.new(intermediate, material, hashlib.sha256).digest()
91
+
92
+
93
+ # -- High-level pairing state -----------------------------------------------
94
+
95
+
96
+ @dataclass
97
+ class PairingResult:
98
+ """Outcome of a successful pairing exchange."""
99
+
100
+ shared_key: bytes
101
+ peer_role: str # "client" or "worker"
102
+ paired_at: float = field(default_factory=time.time)
103
+
104
+
105
+ # -- Pairing messages (JSON-serializable) -----------------------------------
106
+
107
+
108
+ def _encode_bytes(b: bytes) -> str:
109
+ """Encode bytes as hex for JSON transport."""
110
+ return b.hex()
111
+
112
+
113
+ def _decode_bytes(s: str) -> bytes:
114
+ """Decode hex string back to bytes."""
115
+ return bytes.fromhex(s)
116
+
117
+
118
+ def make_challenge_message(challenge: bytes) -> str:
119
+ """Build the JSON pairing-challenge message."""
120
+ return json.dumps({
121
+ "type": "pairing_challenge",
122
+ "challenge": _encode_bytes(challenge),
123
+ })
124
+
125
+
126
+ def parse_challenge_message(msg: str) -> bytes:
127
+ """Extract the challenge nonce from a pairing-challenge message.
128
+
129
+ Raises
130
+ ------
131
+ ValueError
132
+ If the message is not a valid pairing challenge.
133
+ """
134
+ data = json.loads(msg)
135
+ if data.get("type") != "pairing_challenge":
136
+ raise ValueError(f"Expected pairing_challenge, got {data.get('type')!r}")
137
+ return _decode_bytes(data["challenge"])
138
+
139
+
140
+ def make_response_message(response: bytes) -> str:
141
+ """Build the JSON pairing-response message."""
142
+ return json.dumps({
143
+ "type": "pairing_response",
144
+ "response": _encode_bytes(response),
145
+ })
146
+
147
+
148
+ def parse_response_message(msg: str) -> bytes:
149
+ """Extract the response from a pairing-response message.
150
+
151
+ Raises
152
+ ------
153
+ ValueError
154
+ If the message is not a valid pairing response.
155
+ """
156
+ data = json.loads(msg)
157
+ if data.get("type") != "pairing_response":
158
+ raise ValueError(f"Expected pairing_response, got {data.get('type')!r}")
159
+ return _decode_bytes(data["response"])
160
+
161
+
162
+ def make_confirm_message() -> str:
163
+ """Build the JSON pairing-confirmed message."""
164
+ return json.dumps({"type": "pairing_confirmed"})
165
+
166
+
167
+ def parse_confirm_message(msg: str) -> None:
168
+ """Validate a pairing-confirmed message.
169
+
170
+ Raises
171
+ ------
172
+ ValueError
173
+ If the message is not a valid pairing confirmation.
174
+ """
175
+ data = json.loads(msg)
176
+ if data.get("type") != "pairing_confirmed":
177
+ raise ValueError(f"Expected pairing_confirmed, got {data.get('type')!r}")
178
+
179
+
180
+ # -- Key persistence --------------------------------------------------------
181
+
182
+
183
+ def _ensure_key_dir(key_dir: Path | None = None) -> Path:
184
+ """Return the key directory, creating it if necessary."""
185
+ d = key_dir or _DEFAULT_KEY_DIR
186
+ d.mkdir(parents=True, exist_ok=True)
187
+ return d
188
+
189
+
190
+ def save_shared_key(
191
+ shared_key: bytes,
192
+ role: str,
193
+ key_dir: Path | None = None,
194
+ ) -> Path:
195
+ """Persist *shared_key* to disk.
196
+
197
+ Parameters
198
+ ----------
199
+ shared_key
200
+ The 32-byte shared secret.
201
+ role
202
+ ``"client"`` or ``"worker"`` — determines the filename.
203
+ key_dir
204
+ Override the default ``~/.offwork`` directory.
205
+
206
+ Returns
207
+ -------
208
+ Path
209
+ The file that was written.
210
+ """
211
+ d = _ensure_key_dir(key_dir)
212
+ filename = _CLIENT_KEY_FILE if role == "client" else _WORKER_KEY_FILE
213
+ path = d / filename
214
+ path.write_bytes(shared_key)
215
+ # Restrict permissions: owner-only
216
+ path.chmod(0o600)
217
+ logger.info("Saved shared key to %s", path)
218
+ return path
219
+
220
+
221
+ def load_shared_key(
222
+ role: str,
223
+ key_dir: Path | None = None,
224
+ ) -> bytes | None:
225
+ """Load a previously saved shared key, or return *None*.
226
+
227
+ Parameters
228
+ ----------
229
+ role
230
+ ``"client"`` or ``"worker"``.
231
+ key_dir
232
+ Override the default ``~/.offwork`` directory.
233
+ """
234
+ d = _ensure_key_dir(key_dir)
235
+ filename = _CLIENT_KEY_FILE if role == "client" else _WORKER_KEY_FILE
236
+ path = d / filename
237
+ if not path.exists():
238
+ return None
239
+ key = path.read_bytes()
240
+ if len(key) != 32:
241
+ logger.warning("Invalid key file %s (expected 32 bytes, got %d)", path, len(key))
242
+ return None
243
+ return key
244
+
245
+
246
+ def clear_shared_key(
247
+ role: str,
248
+ key_dir: Path | None = None,
249
+ ) -> bool:
250
+ """Delete a saved shared key. Returns ``True`` if a file was removed."""
251
+ d = _ensure_key_dir(key_dir)
252
+ filename = _CLIENT_KEY_FILE if role == "client" else _WORKER_KEY_FILE
253
+ path = d / filename
254
+ if path.exists():
255
+ path.unlink()
256
+ logger.info("Removed shared key %s", path)
257
+ return True
258
+ return False
259
+
260
+
261
+ # -- Backend channel helpers ------------------------------------------------
262
+
263
+ _PAIRING_CHANNEL = "offwork:pairing"
264
+
265
+
266
+ async def initiate_pairing(
267
+ backend: Any,
268
+ pin: str,
269
+ timeout: float = 30.0,
270
+ ) -> PairingResult:
271
+ """Run the *initiator* side of the pairing protocol.
272
+
273
+ The initiator is typically the **worker**: it generates a challenge,
274
+ publishes it on the pairing channel, and waits for the client's
275
+ response.
276
+
277
+ Parameters
278
+ ----------
279
+ backend
280
+ A offwork :class:`~offwork.worker.backends.base.Backend` instance
281
+ with ``send_progress`` / ``get_progress`` used as a simple KV
282
+ channel, or any object that exposes the pairing channel methods.
283
+ pin
284
+ The PIN entered by the user.
285
+ timeout
286
+ Seconds to wait for the peer to respond.
287
+
288
+ Raises
289
+ ------
290
+ PairingError
291
+ On timeout or verification failure.
292
+ """
293
+ intermediate = _derive_intermediate(pin)
294
+ challenge = generate_challenge()
295
+
296
+ # Publish challenge
297
+ challenge_msg = make_challenge_message(challenge)
298
+ await backend.send_progress(_PAIRING_CHANNEL, challenge_msg)
299
+ logger.debug("Pairing: sent challenge")
300
+
301
+ # Wait for response
302
+ deadline = time.monotonic() + timeout
303
+ while time.monotonic() < deadline:
304
+ raw = await backend.get_progress(_PAIRING_CHANNEL + ":response")
305
+ if raw is not None:
306
+ try:
307
+ response = parse_response_message(raw)
308
+ except (ValueError, json.JSONDecodeError):
309
+ await asyncio.sleep(0.5)
310
+ continue
311
+
312
+ if not verify_response(intermediate, challenge, response):
313
+ raise PairingError("PIN mismatch — pairing failed")
314
+
315
+ # Derive shared secret and confirm
316
+ shared = derive_shared_secret(intermediate, challenge)
317
+ confirm_msg = make_confirm_message()
318
+ await backend.send_progress(_PAIRING_CHANNEL + ":confirm", confirm_msg)
319
+ logger.info("Pairing successful (initiator)")
320
+ return PairingResult(shared_key=shared, peer_role="client")
321
+
322
+ await asyncio.sleep(0.5)
323
+
324
+ raise PairingError(f"Pairing timed out after {timeout}s — no response from peer")
325
+
326
+
327
+ async def respond_to_pairing(
328
+ backend: Any,
329
+ pin: str,
330
+ timeout: float = 30.0,
331
+ ) -> PairingResult:
332
+ """Run the *responder* side of the pairing protocol.
333
+
334
+ The responder is typically the **client**: it waits for the worker's
335
+ challenge, computes a response, and waits for confirmation.
336
+
337
+ Parameters
338
+ ----------
339
+ backend
340
+ A offwork backend instance.
341
+ pin
342
+ The PIN entered by the user.
343
+ timeout
344
+ Seconds to wait for the challenge and confirmation.
345
+
346
+ Raises
347
+ ------
348
+ PairingError
349
+ On timeout or if the initiator rejects the response.
350
+ """
351
+ intermediate = _derive_intermediate(pin)
352
+
353
+ # Wait for challenge
354
+ deadline = time.monotonic() + timeout
355
+ challenge: bytes | None = None
356
+ while time.monotonic() < deadline:
357
+ raw = await backend.get_progress(_PAIRING_CHANNEL)
358
+ if raw is not None:
359
+ try:
360
+ challenge = parse_challenge_message(raw)
361
+ break
362
+ except (ValueError, json.JSONDecodeError):
363
+ pass
364
+ await asyncio.sleep(0.5)
365
+
366
+ if challenge is None:
367
+ raise PairingError(f"Pairing timed out after {timeout}s — no challenge from peer")
368
+
369
+ # Compute and send response
370
+ response = compute_response(intermediate, challenge)
371
+ response_msg = make_response_message(response)
372
+ await backend.send_progress(_PAIRING_CHANNEL + ":response", response_msg)
373
+ logger.debug("Pairing: sent response")
374
+
375
+ # Wait for confirmation
376
+ while time.monotonic() < deadline:
377
+ raw = await backend.get_progress(_PAIRING_CHANNEL + ":confirm")
378
+ if raw is not None:
379
+ try:
380
+ parse_confirm_message(raw)
381
+ shared = derive_shared_secret(intermediate, challenge)
382
+ logger.info("Pairing successful (responder)")
383
+ return PairingResult(shared_key=shared, peer_role="worker")
384
+ except (ValueError, json.JSONDecodeError):
385
+ pass
386
+ await asyncio.sleep(0.5)
387
+
388
+ raise PairingError("Pairing failed — initiator did not confirm")
389
+
@@ -0,0 +1,91 @@
1
+ """Progress reporting for remote task execution."""
2
+
3
+ import json
4
+ import contextvars
5
+ from typing import Any, Self, overload
6
+ from dataclasses import dataclass
7
+ from collections.abc import Callable
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ProgressInfo:
12
+ """Progress information reported by a running task.
13
+
14
+ Returned by :meth:`Result.progress`.
15
+ """
16
+
17
+ current: float = 0
18
+ total: float | None = None
19
+ message: str | None = None
20
+
21
+ @property
22
+ def percent(self) -> float | None:
23
+ """Return completion percentage, or ``None`` if *total* is unknown."""
24
+ if self.total is not None and self.total > 0:
25
+ return self.current / self.total * 100
26
+ return None
27
+
28
+ def to_json(self) -> str:
29
+ """Serialize to a JSON string."""
30
+ d: dict[str, Any] = {"current": self.current}
31
+ if self.total is not None:
32
+ d["total"] = self.total
33
+ if self.message is not None:
34
+ d["message"] = self.message
35
+ return json.dumps(d)
36
+
37
+ @classmethod
38
+ def from_json(cls, raw: str | bytes) -> Self:
39
+ """Deserialize from a JSON string or bytes."""
40
+ data = json.loads(raw)
41
+ return cls(
42
+ current=data["current"],
43
+ total=data.get("total"),
44
+ message=data.get("message"),
45
+ )
46
+
47
+
48
+ _progress_callback: contextvars.ContextVar[
49
+ Callable[[float, float | None, str | None], None] | None
50
+ ] = contextvars.ContextVar("offwork_progress", default=None)
51
+
52
+
53
+ @overload
54
+ def progress(percent: float, /, *, message: str | None = None) -> None: ...
55
+
56
+
57
+ @overload
58
+ def progress(current: int, total: int, /, *, message: str | None = None) -> None: ...
59
+
60
+
61
+ def progress(
62
+ _value: float,
63
+ _total: int | None = None,
64
+ /,
65
+ *,
66
+ message: str | None = None,
67
+ ) -> None:
68
+ """Report task progress from within a running function.
69
+
70
+ Call this inside a ``@trace``-decorated function to report progress
71
+ to the client. When called outside a worker, this is a silent no-op.
72
+
73
+ Accepts either a percentage or a current/total pair::
74
+
75
+ progress(75.0) # 75 % complete
76
+ progress(75.0, message="loading model") # 75 % with message
77
+ progress(3, 10) # 3 of 10 (30 %)
78
+ progress(3, 10, message="step 3") # 3 of 10 with message
79
+ """
80
+ cb = _progress_callback.get(None)
81
+ if cb is None:
82
+ return
83
+
84
+ if isinstance(_total, (int, float)):
85
+ if _total <= 0:
86
+ raise ValueError("Progress total must be a positive number.")
87
+ # current / total form
88
+ cb(_value, _total, message)
89
+ else:
90
+ # percent form — normalise to current/100
91
+ cb(_value, 100, message)
@@ -0,0 +1,91 @@
1
+ """Cryptographic task signing and verification.
2
+
3
+ After a client and worker are paired (see :mod:`offwork.core.pairing`),
4
+ they share a secret key. The client uses it to produce an HMAC-SHA256
5
+ signature over the serialized task payload; the worker verifies that
6
+ signature before executing the task.
7
+
8
+ All primitives are stdlib-only (``hashlib``, ``hmac``, ``json``).
9
+ """
10
+
11
+ import hmac
12
+ import json
13
+ import hashlib
14
+ import logging
15
+ from typing import Any
16
+
17
+ from offwork.core.errors import SignatureError
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def compute_signature(payload: str, key: bytes) -> str:
23
+ """Return a hex-encoded HMAC-SHA256 signature of *payload*.
24
+
25
+ Parameters
26
+ ----------
27
+ payload
28
+ The string to sign (typically the JSON body of a task).
29
+ key
30
+ Shared secret derived from the pairing process.
31
+ """
32
+ return hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
33
+
34
+
35
+ def verify_signature(payload: str, signature: str, key: bytes) -> bool:
36
+ """Verify an HMAC-SHA256 *signature* over *payload*.
37
+
38
+ Uses :func:`hmac.compare_digest` for constant-time comparison.
39
+ """
40
+ expected = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
41
+ return hmac.compare_digest(expected, signature)
42
+
43
+
44
+ # -- Signed JSON helpers ----------------------------------------------------
45
+
46
+
47
+ def sign_json(data: dict[str, Any], key: bytes) -> str:
48
+ """Serialize *data* to JSON, attach an HMAC-SHA256 signature, and return the envelope.
49
+
50
+ The returned JSON has the shape::
51
+
52
+ {"payload": "<inner-json>", "signature": "<hex>"}
53
+ """
54
+ payload = json.dumps(data, separators=(",", ":"), sort_keys=True)
55
+ sig = compute_signature(payload, key)
56
+ return json.dumps({"payload": payload, "signature": sig})
57
+
58
+
59
+ def verify_and_load_json(envelope_json: str, key: bytes) -> dict[str, Any]:
60
+ """Parse *envelope_json*, verify the signature, and return the inner data.
61
+
62
+ Raises
63
+ ------
64
+ SignatureError
65
+ If the signature is missing or invalid.
66
+ """
67
+ try:
68
+ envelope = json.loads(envelope_json)
69
+ except (json.JSONDecodeError, TypeError) as exc:
70
+ raise SignatureError(f"Invalid signed envelope: {exc}") from exc
71
+
72
+ payload = envelope.get("payload")
73
+ signature = envelope.get("signature")
74
+ if payload is None or signature is None:
75
+ raise SignatureError("Envelope missing 'payload' or 'signature' field")
76
+
77
+ if not verify_signature(payload, signature, key):
78
+ raise SignatureError("HMAC signature verification failed")
79
+
80
+ return json.loads(payload) # type: ignore[no-any-return]
81
+
82
+
83
+ # -- Key derivation ---------------------------------------------------------
84
+
85
+
86
+ def derive_key(shared_secret: bytes, context: str = "offwork-task-signing") -> bytes:
87
+ """Derive a 32-byte HMAC signing key from a shared secret.
88
+
89
+ Uses HKDF-like expansion via ``HMAC-SHA256(secret, context)``.
90
+ """
91
+ return hmac.new(shared_secret, context.encode("utf-8"), hashlib.sha256).digest()