offwork 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {offwork-0.1.2 → offwork-0.1.4}/PKG-INFO +46 -40
  2. offwork-0.1.4/README.md +119 -0
  3. {offwork-0.1.2 → offwork-0.1.4}/offwork/__init__.py +34 -6
  4. {offwork-0.1.2 → offwork-0.1.4}/offwork/__main__.py +115 -10
  5. offwork-0.1.4/offwork/core/clients.py +142 -0
  6. offwork-0.1.4/offwork/core/ed25519.py +162 -0
  7. offwork-0.1.4/offwork/core/envelope.py +178 -0
  8. {offwork-0.1.2 → offwork-0.1.4}/offwork/core/errors.py +22 -1
  9. offwork-0.1.4/offwork/core/identity.py +111 -0
  10. offwork-0.1.4/offwork/core/signing.py +88 -0
  11. {offwork-0.1.2 → offwork-0.1.4}/offwork/core/task.py +18 -58
  12. {offwork-0.1.2 → offwork-0.1.4}/offwork/core/token.py +18 -25
  13. offwork-0.1.4/offwork/core/version.py +35 -0
  14. {offwork-0.1.2 → offwork-0.1.4}/offwork/graph/tracing.py +12 -0
  15. {offwork-0.1.2 → offwork-0.1.4}/offwork/typing.py +2 -0
  16. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/backends/base.py +11 -0
  17. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/backends/http.py +6 -0
  18. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/deps.py +8 -0
  19. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/remote.py +130 -50
  20. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/result.py +60 -24
  21. offwork-0.1.4/offwork/worker/schedule.py +53 -0
  22. {offwork-0.1.2 → offwork-0.1.4}/pyproject.toml +1 -1
  23. offwork-0.1.2/README.md +0 -113
  24. offwork-0.1.2/offwork/core/signing.py +0 -91
  25. offwork-0.1.2/offwork/core/version.py +0 -10
  26. offwork-0.1.2/offwork/worker/schedule.py +0 -26
  27. {offwork-0.1.2 → offwork-0.1.4}/LICENSE +0 -0
  28. {offwork-0.1.2 → offwork-0.1.4}/offwork/_venv.py +0 -0
  29. {offwork-0.1.2 → offwork-0.1.4}/offwork/core/__init__.py +0 -0
  30. {offwork-0.1.2 → offwork-0.1.4}/offwork/core/models.py +0 -0
  31. {offwork-0.1.2 → offwork-0.1.4}/offwork/core/pairing.py +0 -0
  32. {offwork-0.1.2 → offwork-0.1.4}/offwork/core/progress.py +0 -0
  33. {offwork-0.1.2 → offwork-0.1.4}/offwork/graph/__init__.py +0 -0
  34. {offwork-0.1.2 → offwork-0.1.4}/offwork/graph/analyzer.py +0 -0
  35. {offwork-0.1.2 → offwork-0.1.4}/offwork/graph/decorator.py +0 -0
  36. {offwork-0.1.2 → offwork-0.1.4}/offwork/graph/graph.py +0 -0
  37. {offwork-0.1.2 → offwork-0.1.4}/offwork/graph/store.py +0 -0
  38. {offwork-0.1.2 → offwork-0.1.4}/offwork/py.typed +0 -0
  39. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/__init__.py +0 -0
  40. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/backends/__init__.py +0 -0
  41. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/backends/local.py +0 -0
  42. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/backends/rabbitmq.py +0 -0
  43. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/backends/redis.py +0 -0
  44. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/sandbox/Dockerfile +0 -0
  45. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/sandbox/__init__.py +0 -0
  46. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/sandbox/_protocol.py +0 -0
  47. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/sandbox/docker.py +0 -0
  48. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/sandbox/guest_agent.py +0 -0
  49. {offwork-0.1.2 → offwork-0.1.4}/offwork/worker/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: offwork
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Distributed Python task execution via automatic function serialization
5
5
  License: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -27,32 +27,35 @@ Description-Content-Type: text/markdown
27
27
 
28
28
  # offwork
29
29
 
30
- **Run any Python function on a remote worker — zero setup, zero deployment.**
31
-
32
30
  [![PyPI](https://img.shields.io/pypi/v/offwork)](https://pypi.org/project/offwork/)
33
31
  [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
34
32
  [![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-green)](LICENSE)
35
33
  [![Typed](https://img.shields.io/badge/typing-strict%20mypy-blue)](https://mypy-lang.org/)
36
34
  [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)]()
37
35
 
38
- Add `@offwork.task` to a function. offwork captures its source, all dependencies, and all imports automatically. Workers reconstruct and execute everything from scratch — no shared filesystem, no deployment pipeline. Missing packages are installed on the fly.
36
+ **Run any Python function on a remote worker with just two lines of code.**
37
+
38
+ Put `.connect()` somewhere at the start of your script, add `@offwork.task` to your function, that's it.
39
+ You can now run it remotely — no shared codebase, no deployment pipeline.
40
+
41
+ `offwork` captures its entire dependency graph (helpers, imports, closures, constants) and ships it to the worker as a self-contained payload. The worker doesn't need to have any prior knowledge of your code.
39
42
 
40
43
  ## Quick start
41
44
 
42
45
  ```bash
43
46
  pip install offwork
47
+ offwork worker --backend local://localhost:9748 --tmp # start a worker in a temp venv
44
48
  ```
45
49
 
46
50
  ```python
47
51
  import asyncio, math, offwork
48
- import offwork
49
52
 
50
53
  offwork.connect("local://localhost:9748")
51
54
 
52
- def add(a, b):
55
+ def add(a: float, b: float) -> float:
53
56
  return a + b
54
57
 
55
- @offwork.task
58
+ @offwork.task # only the entry point needs this - add() is captured automatically
56
59
  def hypotenuse(a: float, b: float) -> float:
57
60
  return math.sqrt(add(a**2, b**2))
58
61
 
@@ -62,66 +65,69 @@ async def main():
62
65
  asyncio.run(main())
63
66
  ```
64
67
 
65
- Only the entry point needs `@offwork.task` everything it calls is captured automatically.
68
+ `.run()` serializes the function graph, submits it to the worker, and returns the result. The worker reconstructs source, installs any missing packages, and executes.
69
+
70
+ ### Multi-machine
71
+
72
+ Swap `local://` for Redis or RabbitMQ to run on a remote worker:
66
73
 
67
74
  ```bash
68
- offwork worker --backend local://localhost:9748 --tmp # start a worker
69
- python my_script.py # 5.0
75
+ pip install offwork[redis]
76
+ offwork worker --backend redis://other-machine:6379
70
77
  ```
71
78
 
72
- For multi-machine, swap `local://` for `redis://` or an `https://` managed broker URL.
79
+ See [Features](docs/FEATURES.md) for the full API.
73
80
 
74
81
  ## Features
75
82
 
76
83
  | | |
77
84
  |-|-|
78
- | **Auto dependency capture** | Functions, classes, constants, closuresrecursive AST analysis |
79
- | **Package auto-install** | Workers `pip install` missing packages before execution |
80
- | **Async-native** | `.run()`, `.start()`, `.map()`, `asyncio.gather` |
85
+ | **Async-native** | `.run()`, `.start()`, `.map()`, `asyncio.gather`all coroutines |
86
+ | **Scheduling** | `.run_in(delay)`, `.run_at(datetime)`, `.run_every(interval)` with cancellation |
81
87
  | **Retry & timeout** | `@offwork.task(timeout=30, retries=3)` with exponential backoff |
82
- | **Scheduling** | `.run_in(delay)`, `.run_at(datetime)`, `.run_every(freq)` with cancellation |
83
88
  | **Throttling** | `@offwork.task(throttle=timedelta(hours=24)/50)` — rate-limit executions |
84
89
  | **Progress & cancellation** | `offwork.progress(3, 10)` inside tasks; `await future.cancel()` on client |
85
- | **Heartbeat & stall detection** | Workers heartbeat; clients raise `TaskStalled` on silence |
86
- | **Content-hash caching** | Same code = cache hit, regardless of client |
87
- | **Pluggable backends** | `local://` (TCP), `redis://`, `amqp://` (RabbitMQ), `https://` (hosted) |
88
- | **Docker sandbox** | Container isolation, transparent to clients |
90
+ | **Heartbeat & stall detection** | Workers heartbeat every second; clients raise `TaskStalled` on silence |
91
+ | **Package auto-install** | Workers `pip install` missing packages before execution |
92
+ | **Docker sandbox** | Optional container isolation, fully transparent to clients |
89
93
  | **Signed execution** | Pre-shared token or PIN pairing + HMAC-SHA256 task authentication |
90
- | **Graceful shutdown** | Ctrl+C drains in-flight tasks; second Ctrl+C force-quits |
91
94
 
92
- ## Sandbox
95
+ ### Security
96
+
97
+ #### Signing
93
98
 
94
- Run tasks inside Docker containers for isolation transparent to clients:
99
+ To make sure your worker only executes trusted code, use `--require-signing`.<br>
100
+ Configure a shared token or pair interactively with a PIN. See [Signing & Pairing](docs/SIGNING.md) for details.
95
101
 
96
102
  ```bash
97
- offwork sandbox setup # build image (once)
98
- offwork worker --backend redis://localhost:6379 --sandbox # run with isolation
103
+ # Token (CI/CD)
104
+ offwork token generate
105
+ export OFFWORK_SIGNING_TOKEN=<token> # client & worker
106
+ offwork worker --backend redis://localhost:6379 --require-signing
107
+
108
+ # PIN pairing (interactive)
109
+ offwork worker --backend redis://localhost:6379 --pair # shows 6-digit PIN
110
+ offwork pair --backend redis://localhost:6379 # client: enter PIN
99
111
  ```
100
112
 
101
- See [Sandbox](docs/SANDBOX.md) for configuration.
113
+ After pairing, tasks are signed automatically with no client code changes. See [Signing & Pairing](docs/SIGNING.md).
102
114
 
103
- ## Signing
115
+ #### Sandbox
104
116
 
105
- Pre-shared token or PIN-based pairing + HMAC-SHA256 workers reject untrusted or tampered tasks:
117
+ To avoid side-effects on the worker machine, run tasks inside Docker containers:
106
118
 
107
119
  ```bash
108
- # Token-based (recommended for CI/CD)
109
- offwork token generate
110
- export OFFWORK_SIGNING_TOKEN=<token> # set on client & worker
111
- offwork worker --backend redis://localhost:6379 --require-signing
112
-
113
- # PIN-based pairing (interactive)
114
- offwork worker --backend redis://localhost:6379 --pair # displays a 6-digit PIN
115
- offwork pair --backend redis://localhost:6379 # on client: enter the PIN
120
+ offwork sandbox setup # build image (once)
121
+ offwork worker --backend redis://localhost:6379 --sandbox # run with isolation
116
122
  ```
117
123
 
118
- After setup, tasks are signed automatically. No client-side code changes. See [Signing & Pairing](docs/SIGNING.md).
124
+ See [Sandbox](docs/SANDBOX.md) for configuration.
119
125
 
120
126
  ## Documentation
121
127
 
122
128
  | | |
123
129
  |-|-|
124
- | **[Quick Start](docs/QUICK_START.md)** | Tutorial and API walkthrough |
130
+ | **[Features](docs/FEATURES.md)** | Full feature guide and API walkthrough |
125
131
  | **[Technical Overview](docs/TECHNICAL_OVERVIEW.md)** | Architecture, serialization format, internals |
126
132
  | **[Signing & Pairing](docs/SIGNING.md)** | Cryptographic task signing protocol |
127
133
  | **[Sandbox](docs/SANDBOX.md)** | Docker container isolation |
@@ -129,11 +135,11 @@ After setup, tasks are signed automatically. No client-side code changes. See [S
129
135
  ## Examples
130
136
 
131
137
  ```bash
132
- offwork worker --backend local://localhost:9748 --tmp
133
- offwork run examples/remote_execution.py
138
+ offwork worker --backend local://localhost:9748 --tmp # start worker
139
+ offwork run examples/remote_execution.py # run any example
134
140
  ```
135
141
 
136
- [`remote_execution.py`](examples/remote_execution.py) · [`async_execution.py`](examples/async_execution.py) · [`package_installation.py`](examples/package_installation.py) · [`progress_reporting.py`](examples/progress_reporting.py) · [`cancellation.py`](examples/cancellation.py) · [`scheduling.py`](examples/scheduling.py) · [`throttling_and_retry.py`](examples/throttling_and_retry.py) · [`large_module.py`](examples/large_module.py)
142
+ See [examples/README.md](examples/README.md) for a guide to all examples.
137
143
 
138
144
  ## License
139
145
 
@@ -0,0 +1,119 @@
1
+ # offwork
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/offwork)](https://pypi.org/project/offwork/)
4
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
5
+ [![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-green)](LICENSE)
6
+ [![Typed](https://img.shields.io/badge/typing-strict%20mypy-blue)](https://mypy-lang.org/)
7
+ [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)]()
8
+
9
+ **Run any Python function on a remote worker with just two lines of code.**
10
+
11
+ Put `.connect()` somewhere at the start of your script, add `@offwork.task` to your function, that's it.
12
+ You can now run it remotely — no shared codebase, no deployment pipeline.
13
+
14
+ `offwork` captures its entire dependency graph (helpers, imports, closures, constants) and ships it to the worker as a self-contained payload. The worker doesn't need to have any prior knowledge of your code.
15
+
16
+ ## Quick start
17
+
18
+ ```bash
19
+ pip install offwork
20
+ offwork worker --backend local://localhost:9748 --tmp # start a worker in a temp venv
21
+ ```
22
+
23
+ ```python
24
+ import asyncio, math, offwork
25
+
26
+ offwork.connect("local://localhost:9748")
27
+
28
+ def add(a: float, b: float) -> float:
29
+ return a + b
30
+
31
+ @offwork.task # only the entry point needs this - add() is captured automatically
32
+ def hypotenuse(a: float, b: float) -> float:
33
+ return math.sqrt(add(a**2, b**2))
34
+
35
+ async def main():
36
+ print(await hypotenuse.run(3.0, 4.0)) # 5.0
37
+
38
+ asyncio.run(main())
39
+ ```
40
+
41
+ `.run()` serializes the function graph, submits it to the worker, and returns the result. The worker reconstructs source, installs any missing packages, and executes.
42
+
43
+ ### Multi-machine
44
+
45
+ Swap `local://` for Redis or RabbitMQ to run on a remote worker:
46
+
47
+ ```bash
48
+ pip install offwork[redis]
49
+ offwork worker --backend redis://other-machine:6379
50
+ ```
51
+
52
+ See [Features](docs/FEATURES.md) for the full API.
53
+
54
+ ## Features
55
+
56
+ | | |
57
+ |-|-|
58
+ | **Async-native** | `.run()`, `.start()`, `.map()`, `asyncio.gather` — all coroutines |
59
+ | **Scheduling** | `.run_in(delay)`, `.run_at(datetime)`, `.run_every(interval)` with cancellation |
60
+ | **Retry & timeout** | `@offwork.task(timeout=30, retries=3)` with exponential backoff |
61
+ | **Throttling** | `@offwork.task(throttle=timedelta(hours=24)/50)` — rate-limit executions |
62
+ | **Progress & cancellation** | `offwork.progress(3, 10)` inside tasks; `await future.cancel()` on client |
63
+ | **Heartbeat & stall detection** | Workers heartbeat every second; clients raise `TaskStalled` on silence |
64
+ | **Package auto-install** | Workers `pip install` missing packages before execution |
65
+ | **Docker sandbox** | Optional container isolation, fully transparent to clients |
66
+ | **Signed execution** | Pre-shared token or PIN pairing + HMAC-SHA256 task authentication |
67
+
68
+ ### Security
69
+
70
+ #### Signing
71
+
72
+ To make sure your worker only executes trusted code, use `--require-signing`.<br>
73
+ Configure a shared token or pair interactively with a PIN. See [Signing & Pairing](docs/SIGNING.md) for details.
74
+
75
+ ```bash
76
+ # Token (CI/CD)
77
+ offwork token generate
78
+ export OFFWORK_SIGNING_TOKEN=<token> # client & worker
79
+ offwork worker --backend redis://localhost:6379 --require-signing
80
+
81
+ # PIN pairing (interactive)
82
+ offwork worker --backend redis://localhost:6379 --pair # shows 6-digit PIN
83
+ offwork pair --backend redis://localhost:6379 # client: enter PIN
84
+ ```
85
+
86
+ After pairing, tasks are signed automatically with no client code changes. See [Signing & Pairing](docs/SIGNING.md).
87
+
88
+ #### Sandbox
89
+
90
+ To avoid side-effects on the worker machine, run tasks inside Docker containers:
91
+
92
+ ```bash
93
+ offwork sandbox setup # build image (once)
94
+ offwork worker --backend redis://localhost:6379 --sandbox # run with isolation
95
+ ```
96
+
97
+ See [Sandbox](docs/SANDBOX.md) for configuration.
98
+
99
+ ## Documentation
100
+
101
+ | | |
102
+ |-|-|
103
+ | **[Features](docs/FEATURES.md)** | Full feature guide and API walkthrough |
104
+ | **[Technical Overview](docs/TECHNICAL_OVERVIEW.md)** | Architecture, serialization format, internals |
105
+ | **[Signing & Pairing](docs/SIGNING.md)** | Cryptographic task signing protocol |
106
+ | **[Sandbox](docs/SANDBOX.md)** | Docker container isolation |
107
+
108
+ ## Examples
109
+
110
+ ```bash
111
+ offwork worker --backend local://localhost:9748 --tmp # start worker
112
+ offwork run examples/remote_execution.py # run any example
113
+ ```
114
+
115
+ See [examples/README.md](examples/README.md) for a guide to all examples.
116
+
117
+ ## License
118
+
119
+ [AGPL-3.0](LICENSE)
@@ -8,14 +8,18 @@ from offwork.core.task import Task
8
8
  from offwork.core.errors import (
9
9
  Error,
10
10
  RemoteError,
11
+ ReplayError,
11
12
  TaskStalled,
12
13
  WorkerError,
13
14
  PairingError,
15
+ StaleTaskError,
14
16
  TaskCancelled,
15
17
  SignatureError,
16
18
  DependencyError,
17
19
  ThrottleError,
18
20
  WorkerOnlyError,
21
+ ClientRevokedError,
22
+ IdentityMismatchError,
19
23
  )
20
24
  from offwork.core.models import ImportInfo, FunctionNode
21
25
  from offwork.graph.graph import Graph
@@ -31,18 +35,29 @@ from offwork.core.pairing import (
31
35
  respond_to_pairing,
32
36
  )
33
37
  from offwork.core.signing import (
34
- sign_json,
38
+ NonceLRU,
35
39
  derive_key,
36
40
  verify_signature,
37
41
  compute_signature,
38
- verify_and_load_json,
39
42
  )
40
43
  from offwork.core.token import (
41
44
  load_token,
42
45
  save_token,
43
46
  clear_token,
44
47
  generate_token,
45
- resolve_signing_key,
48
+ resolve_root_token,
49
+ )
50
+ from offwork.core.identity import (
51
+ get_client_id,
52
+ get_public_key,
53
+ clear_identity,
54
+ get_identity_seed,
55
+ get_identity_fingerprint,
56
+ )
57
+ from offwork.core.clients import KnownClients, ClientEntry
58
+ from offwork.core.envelope import (
59
+ build_signed_envelope,
60
+ verify_task_envelope,
46
61
  )
47
62
  from offwork.core.version import _VERSION
48
63
  from offwork.core.progress import ProgressInfo
@@ -131,6 +146,10 @@ __all__ = [
131
146
  "TaskCancelled",
132
147
  "ThrottleError",
133
148
  "SignatureError",
149
+ "ReplayError",
150
+ "StaleTaskError",
151
+ "ClientRevokedError",
152
+ "IdentityMismatchError",
134
153
  "PairingError",
135
154
  "WorkerOnlyError",
136
155
  # Graph
@@ -139,15 +158,24 @@ __all__ = [
139
158
  # Signing
140
159
  "compute_signature",
141
160
  "verify_signature",
142
- "sign_json",
143
- "verify_and_load_json",
144
161
  "derive_key",
162
+ "NonceLRU",
163
+ "build_signed_envelope",
164
+ "verify_task_envelope",
145
165
  # Token
146
166
  "generate_token",
147
167
  "save_token",
148
168
  "load_token",
149
169
  "clear_token",
150
- "resolve_signing_key",
170
+ "resolve_root_token",
171
+ # Identity / clients
172
+ "get_client_id",
173
+ "get_identity_seed",
174
+ "get_public_key",
175
+ "get_identity_fingerprint",
176
+ "clear_identity",
177
+ "KnownClients",
178
+ "ClientEntry",
151
179
  # Pairing
152
180
  "generate_pin",
153
181
  "save_shared_key",
@@ -44,6 +44,8 @@ def _build_worker_cmd(python: str, args: argparse.Namespace) -> list[str]:
44
44
  cmd.extend(["--log-level", args.log_level])
45
45
  if args.require_signing:
46
46
  cmd.append("--require-signing")
47
+ if args.clock_skew != 300.0:
48
+ cmd.extend(["--clock-skew", str(args.clock_skew)])
47
49
  if args.sandbox:
48
50
  cmd.append("--sandbox")
49
51
  return cmd
@@ -108,6 +110,7 @@ def _cmd_worker(args: argparse.Namespace) -> None:
108
110
  auto_install=not args.no_auto_install,
109
111
  sandbox=bool(args.sandbox),
110
112
  require_signing=bool(args.require_signing),
113
+ clock_skew=float(args.clock_skew),
111
114
  ))
112
115
 
113
116
 
@@ -142,6 +145,7 @@ async def _pair_then_serve(args: argparse.Namespace) -> None:
142
145
  auto_install=not args.no_auto_install,
143
146
  sandbox=bool(args.sandbox),
144
147
  require_signing=True,
148
+ clock_skew=float(args.clock_skew),
145
149
  )
146
150
 
147
151
 
@@ -395,7 +399,9 @@ def _add_worker_parser(sub: "argparse._SubParsersAction[argparse.ArgumentParser]
395
399
  p.add_argument("--sandbox", action="store_true", default=False,
396
400
  help="Run function execution inside an isolated Docker sandbox.")
397
401
  p.add_argument("--require-signing", action="store_true", default=False,
398
- help="Only accept tasks with valid HMAC signatures from paired clients.")
402
+ help="Only accept tasks with valid signed envelopes (per-client HMAC + Ed25519).")
403
+ p.add_argument("--clock-skew", type=float, default=300.0, metavar="SECONDS",
404
+ help="Maximum clock skew between client and worker (default: 300s)")
399
405
  p.add_argument("--pair", action="store_true", default=False,
400
406
  help="Generate a pairing PIN, wait for a client to pair, then start "
401
407
  "serving with signing enabled.")
@@ -649,6 +655,7 @@ def _build_parser() -> argparse.ArgumentParser:
649
655
  _add_sandbox_parser(sub)
650
656
  _add_pair_parser(sub)
651
657
  _add_token_parser(sub)
658
+ _add_clients_parser(sub)
652
659
  return parser
653
660
 
654
661
 
@@ -703,22 +710,27 @@ def _cmd_token_generate(args: argparse.Namespace) -> None:
703
710
 
704
711
 
705
712
  def _cmd_token_show(_args: argparse.Namespace) -> None:
706
- """Show the current signing token status."""
713
+ """Show the current signing token + local identity status."""
707
714
  from offwork.core.token import load_token, _TOKEN_ENV_VAR
715
+ from offwork.core.identity import get_client_id, get_identity_fingerprint
708
716
 
709
717
  env_val = os.environ.get(_TOKEN_ENV_VAR)
710
718
  if env_val is not None:
711
719
  print(f" Source: {_TOKEN_ENV_VAR} environment variable")
712
720
  print(f" Token: {env_val.strip()[:16]}... (truncated)")
713
- return
714
-
715
- token = load_token()
716
- if token is not None:
717
- print(f" Source: ~/.offwork/token")
718
- print(f" Token: {token[:16]}... (truncated)")
719
721
  else:
720
- print(" No signing token configured.")
721
- print(" Generate one with: offwork token generate")
722
+ token = load_token()
723
+ if token is not None:
724
+ print(f" Source: ~/.offwork/token")
725
+ print(f" Token: {token[:16]}... (truncated)")
726
+ else:
727
+ print(" No signing token configured.")
728
+ print(" Generate one with: offwork token generate")
729
+
730
+ print()
731
+ print(" Local identity:")
732
+ print(f" client_id: {get_client_id()}")
733
+ print(f" fingerprint: {get_identity_fingerprint()}")
722
734
 
723
735
 
724
736
  def _cmd_token_clear(_args: argparse.Namespace) -> None:
@@ -749,6 +761,98 @@ def _add_token_parser(sub: "argparse._SubParsersAction[argparse.ArgumentParser]"
749
761
  token_sub.add_parser("clear", help="Remove the saved token")
750
762
 
751
763
 
764
+ # -- Clients (known-clients registry) ---------------------------------------
765
+
766
+
767
+ def _cmd_clients(args: argparse.Namespace) -> None:
768
+ """Handle ``offwork clients`` subcommands."""
769
+ action = getattr(args, "clients_action", None)
770
+ if action is None:
771
+ print("Usage: offwork clients {list|show|revoke|approve}", file=sys.stderr)
772
+ sys.exit(1)
773
+ handlers = {
774
+ "list": _cmd_clients_list,
775
+ "show": _cmd_clients_show,
776
+ "revoke": _cmd_clients_revoke,
777
+ "approve": _cmd_clients_approve,
778
+ }
779
+ handlers[action](args)
780
+
781
+
782
+ def _format_ts(ts: float) -> str:
783
+ import datetime as _dt
784
+
785
+ return _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
786
+
787
+
788
+ def _cmd_clients_list(_args: argparse.Namespace) -> None:
789
+ from offwork.core.clients import KnownClients
790
+
791
+ entries = KnownClients().list_clients()
792
+ if not entries:
793
+ print("No known clients.")
794
+ return
795
+ print(f"{'CLIENT_ID':<34} {'FINGERPRINT':<18} "
796
+ f"{'FIRST_SEEN':<20} {'LAST_SEEN':<20} STATE")
797
+ import hashlib
798
+ for e in entries:
799
+ fp = hashlib.sha256(bytes.fromhex(e.pubkey)).hexdigest()[:16]
800
+ state = "revoked" if e.revoked else "active"
801
+ print(f"{e.client_id:<34} {fp:<18} "
802
+ f"{_format_ts(e.first_seen):<20} {_format_ts(e.last_seen):<20} {state}")
803
+
804
+
805
+ def _cmd_clients_show(args: argparse.Namespace) -> None:
806
+ import hashlib
807
+
808
+ from offwork.core.clients import KnownClients
809
+
810
+ entry = KnownClients().get(args.client_id)
811
+ if entry is None:
812
+ print(f"No client with id {args.client_id!r}.", file=sys.stderr)
813
+ sys.exit(1)
814
+ fp = hashlib.sha256(bytes.fromhex(entry.pubkey)).hexdigest()[:16]
815
+ print(f" client_id: {entry.client_id}")
816
+ print(f" pubkey: {entry.pubkey}")
817
+ print(f" fingerprint: {fp}")
818
+ print(f" first_seen: {_format_ts(entry.first_seen)}")
819
+ print(f" last_seen: {_format_ts(entry.last_seen)}")
820
+ print(f" revoked: {entry.revoked}")
821
+
822
+
823
+ def _cmd_clients_revoke(args: argparse.Namespace) -> None:
824
+ from offwork.core.clients import KnownClients
825
+
826
+ if KnownClients().revoke(args.client_id):
827
+ print(f"Revoked client {args.client_id}.")
828
+ else:
829
+ print(f"Client {args.client_id} not found or already revoked.")
830
+
831
+
832
+ def _cmd_clients_approve(args: argparse.Namespace) -> None:
833
+ from offwork.core.clients import KnownClients
834
+
835
+ if KnownClients().approve(args.client_id):
836
+ print(f"Un-revoked client {args.client_id}.")
837
+ else:
838
+ print(f"Client {args.client_id} not found or not revoked.")
839
+
840
+
841
+ def _add_clients_parser(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
842
+ p = sub.add_parser(
843
+ "clients",
844
+ help="Inspect and manage the known-clients registry (worker side)",
845
+ )
846
+ clients_sub = p.add_subparsers(dest="clients_action")
847
+ clients_sub.add_parser("list", help="List all known clients")
848
+ show = clients_sub.add_parser("show", help="Show details for one client")
849
+ show.add_argument("client_id", help="Client id to show")
850
+ rev = clients_sub.add_parser("revoke", help="Revoke a client")
851
+ rev.add_argument("client_id", help="Client id to revoke")
852
+ appr = clients_sub.add_parser("approve", help="Un-revoke a client")
853
+ appr.add_argument("client_id", help="Client id to un-revoke")
854
+
855
+
752
856
  _COMMAND_HANDLERS: dict[str, Callable[[argparse.Namespace], None]] = {
753
857
  "worker": _cmd_worker,
754
858
  "run": _cmd_run,
@@ -758,6 +862,7 @@ _COMMAND_HANDLERS: dict[str, Callable[[argparse.Namespace], None]] = {
758
862
  "sandbox": _cmd_sandbox,
759
863
  "pair": _dispatch_pair,
760
864
  "token": _cmd_token,
865
+ "clients": _cmd_clients,
761
866
  }
762
867
 
763
868