exolimbs 0.4.0__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.
exolimbs-0.4.0/LICENSE ADDED
@@ -0,0 +1,32 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 seanyang1983
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ -------------------------------------------------------------------------------
24
+ THIRD-PARTY NOTICES
25
+
26
+ This project is a compatibility bridge. It does NOT bundle or redistribute
27
+ OpenClaw. When the CLI backend is used, it invokes a user-installed `openclaw`
28
+ binary. OpenClaw is distributed under the MIT License, Copyright (c) 2025
29
+ Peter Steinberger / OpenClaw Foundation. "OpenClaw", "ClawHub" and related marks
30
+ belong to their respective owners; this project is not affiliated with or
31
+ endorsed by them. If you choose to redistribute any OpenClaw components, you must
32
+ include their MIT license and attribution.
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: exolimbs
3
+ Version: 0.4.0
4
+ Summary: Run ClawHub skills, sandbox, Playwright browser & multi-language runtimes as Hermes hands/feet. Structured JSON, zero extra LLM tokens.
5
+ Author-email: seanyang1983 <yase19636404@163.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/seanyang1983/exolimbs
8
+ Project-URL: Issues, https://github.com/seanyang1983/exolimbs/issues
9
+ Project-URL: Store, https://your-store.example.com
10
+ Keywords: hermes,openclaw,clawhub,agent,sandbox,playwright,skills
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Intended Audience :: Developers
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: PyYAML<7,>=6
19
+ Provides-Extra: browser
20
+ Requires-Dist: playwright<2,>=1.44; extra == "browser"
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest<9,>=8; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # Exolimbs — Hermes plugin
26
+
27
+ > Give your Hermes agent OpenClaw / ClawHub's hands and feet.
28
+ > Run community skills, an isolated sandbox, a Playwright browser, and
29
+ > multi-language runtimes — through a tiny structured-JSON tool surface, with
30
+ > **zero extra LLM tokens** on the execution path.
31
+
32
+ Hermes is the brain. Exolimbs is the deterministic execution substrate. It
33
+ never calls a model itself, has no conversation/memory/UI, and exposes six tools.
34
+
35
+ > ℹ️ "Exolimbs" is the working title. **Rename before any commercial
36
+ > launch** — see `DESIGN.md` → *Naming & legal*. The package, plugin name, and
37
+ > entry point are all in one place each, so renaming is a 5-minute job.
38
+
39
+ ## Tools
40
+
41
+ | Tool | What it does |
42
+ |------|--------------|
43
+ | `claw_skill_search` | Search the ClawHub registry |
44
+ | `claw_skill_install` | Install + verify a skill (slug / `git:owner/repo@ref` / local path) |
45
+ | `claw_skill_run` | Deterministically run a skill's script entrypoint |
46
+ | `claw_sandbox_exec` | Run a command in an isolated (Docker) sandbox with rollback |
47
+ | `claw_browser` | Playwright browser automation via a structured action list |
48
+ | `claw_runtime` | Quick snippet in python / node / bash / ruby / go |
49
+
50
+ ## Two interchangeable backends
51
+
52
+ Set `exolimbs.backend` in `~/.hermes/config.yaml` (or `EXOLIMBS_BACKEND`):
53
+
54
+ | Mode | Behaviour |
55
+ |------|-----------|
56
+ | `cli` | Bridges to the real `openclaw` / `clawhub` CLIs (`--non-interactive --json`). Best registry parity. Requires Node + OpenClaw installed. |
57
+ | `native` | Fully decoupled Python substrate. No Node needed. Handles sandbox/browser/runtime + `git:`/local skill installs natively. |
58
+ | `auto` (default) | `cli` if the `openclaw` binary is on PATH, else `native`. |
59
+
60
+ ## Install
61
+
62
+ **As a directory plugin (simplest):**
63
+
64
+ ```bash
65
+ # copy the package into your Hermes plugins dir
66
+ cp -r exolimbs ~/.hermes/plugins/exolimbs
67
+ hermes plugins enable exolimbs
68
+ ```
69
+
70
+ **As a pip package (distribution):**
71
+
72
+ ```bash
73
+ pip install exolimbs # core
74
+ pip install "exolimbs[browser]" # + Playwright
75
+ playwright install chromium # one-time browser download
76
+ hermes plugins enable exolimbs
77
+ ```
78
+
79
+ Verify inside a session:
80
+
81
+ ```
82
+ /exo doctor
83
+ ```
84
+
85
+ ## Configure (`~/.hermes/config.yaml`)
86
+
87
+ ```yaml
88
+ exolimbs:
89
+ backend: auto # auto | cli | native
90
+ sandbox_enabled: true
91
+ sandbox_image: "python:3.12-slim"
92
+ sandbox_network: false
93
+ default_timeout_s: 120
94
+ max_retries: 2
95
+ rollback: true
96
+ registry_base_url: "https://clawhub.ai"
97
+ browser_headless: true
98
+ # Pro:
99
+ audit_log: false
100
+ # license_key: "EXL1...." # or set EXOLIMBS_LICENSE in .env
101
+ ```
102
+
103
+ ## Free vs Pro (open-core)
104
+
105
+ - **Free:** all six tools, both backends, retry/rollback, trust-envelope verify.
106
+ - **Pro:** JSONL audit log, curated/verified skill packs, auto-update, priority
107
+ support. Unlock with `EXOLIMBS_LICENSE`. See `DESIGN.md` for pricing/channels.
108
+
109
+ Licenses are signed **Ed25519** tokens (`EXL1.<payload>.<signature>`), verified
110
+ fully **offline** against an embedded public key — no phone-home, air-gap safe.
111
+
112
+ **Issuing licenses (vendor-side only):**
113
+
114
+ ```bash
115
+ python scripts/gen_keys.py # once: make keypair (seed -> .secrets/, git-ignored)
116
+ # paste printed public key into exolimbs/_licensing.py
117
+ python scripts/issue_license.py --sub alice@example.com --tier pro --days 365
118
+ # -> prints EXL1.<...> ; customer sets EXOLIMBS_LICENSE to that token
119
+ ```
120
+
121
+ Revoke an issued license by adding its `jti` to `_REVOKED` in `_licensing.py` and
122
+ shipping a release.
123
+
124
+ ## Security
125
+
126
+ Third-party skills are untrusted code. Prefer `claw_sandbox_exec` with
127
+ `network: false` for anything you don't fully trust. Without Docker, sandbox
128
+ calls run locally and are flagged `"sandboxed": false`.
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ pip install -e ".[dev,browser]"
134
+ pytest -q
135
+ ```
136
+
137
+ ## License
138
+
139
+ MIT (this plugin). Not affiliated with OpenClaw/ClawHub. See `LICENSE`.
@@ -0,0 +1,115 @@
1
+ # Exolimbs — Hermes plugin
2
+
3
+ > Give your Hermes agent OpenClaw / ClawHub's hands and feet.
4
+ > Run community skills, an isolated sandbox, a Playwright browser, and
5
+ > multi-language runtimes — through a tiny structured-JSON tool surface, with
6
+ > **zero extra LLM tokens** on the execution path.
7
+
8
+ Hermes is the brain. Exolimbs is the deterministic execution substrate. It
9
+ never calls a model itself, has no conversation/memory/UI, and exposes six tools.
10
+
11
+ > ℹ️ "Exolimbs" is the working title. **Rename before any commercial
12
+ > launch** — see `DESIGN.md` → *Naming & legal*. The package, plugin name, and
13
+ > entry point are all in one place each, so renaming is a 5-minute job.
14
+
15
+ ## Tools
16
+
17
+ | Tool | What it does |
18
+ |------|--------------|
19
+ | `claw_skill_search` | Search the ClawHub registry |
20
+ | `claw_skill_install` | Install + verify a skill (slug / `git:owner/repo@ref` / local path) |
21
+ | `claw_skill_run` | Deterministically run a skill's script entrypoint |
22
+ | `claw_sandbox_exec` | Run a command in an isolated (Docker) sandbox with rollback |
23
+ | `claw_browser` | Playwright browser automation via a structured action list |
24
+ | `claw_runtime` | Quick snippet in python / node / bash / ruby / go |
25
+
26
+ ## Two interchangeable backends
27
+
28
+ Set `exolimbs.backend` in `~/.hermes/config.yaml` (or `EXOLIMBS_BACKEND`):
29
+
30
+ | Mode | Behaviour |
31
+ |------|-----------|
32
+ | `cli` | Bridges to the real `openclaw` / `clawhub` CLIs (`--non-interactive --json`). Best registry parity. Requires Node + OpenClaw installed. |
33
+ | `native` | Fully decoupled Python substrate. No Node needed. Handles sandbox/browser/runtime + `git:`/local skill installs natively. |
34
+ | `auto` (default) | `cli` if the `openclaw` binary is on PATH, else `native`. |
35
+
36
+ ## Install
37
+
38
+ **As a directory plugin (simplest):**
39
+
40
+ ```bash
41
+ # copy the package into your Hermes plugins dir
42
+ cp -r exolimbs ~/.hermes/plugins/exolimbs
43
+ hermes plugins enable exolimbs
44
+ ```
45
+
46
+ **As a pip package (distribution):**
47
+
48
+ ```bash
49
+ pip install exolimbs # core
50
+ pip install "exolimbs[browser]" # + Playwright
51
+ playwright install chromium # one-time browser download
52
+ hermes plugins enable exolimbs
53
+ ```
54
+
55
+ Verify inside a session:
56
+
57
+ ```
58
+ /exo doctor
59
+ ```
60
+
61
+ ## Configure (`~/.hermes/config.yaml`)
62
+
63
+ ```yaml
64
+ exolimbs:
65
+ backend: auto # auto | cli | native
66
+ sandbox_enabled: true
67
+ sandbox_image: "python:3.12-slim"
68
+ sandbox_network: false
69
+ default_timeout_s: 120
70
+ max_retries: 2
71
+ rollback: true
72
+ registry_base_url: "https://clawhub.ai"
73
+ browser_headless: true
74
+ # Pro:
75
+ audit_log: false
76
+ # license_key: "EXL1...." # or set EXOLIMBS_LICENSE in .env
77
+ ```
78
+
79
+ ## Free vs Pro (open-core)
80
+
81
+ - **Free:** all six tools, both backends, retry/rollback, trust-envelope verify.
82
+ - **Pro:** JSONL audit log, curated/verified skill packs, auto-update, priority
83
+ support. Unlock with `EXOLIMBS_LICENSE`. See `DESIGN.md` for pricing/channels.
84
+
85
+ Licenses are signed **Ed25519** tokens (`EXL1.<payload>.<signature>`), verified
86
+ fully **offline** against an embedded public key — no phone-home, air-gap safe.
87
+
88
+ **Issuing licenses (vendor-side only):**
89
+
90
+ ```bash
91
+ python scripts/gen_keys.py # once: make keypair (seed -> .secrets/, git-ignored)
92
+ # paste printed public key into exolimbs/_licensing.py
93
+ python scripts/issue_license.py --sub alice@example.com --tier pro --days 365
94
+ # -> prints EXL1.<...> ; customer sets EXOLIMBS_LICENSE to that token
95
+ ```
96
+
97
+ Revoke an issued license by adding its `jti` to `_REVOKED` in `_licensing.py` and
98
+ shipping a release.
99
+
100
+ ## Security
101
+
102
+ Third-party skills are untrusted code. Prefer `claw_sandbox_exec` with
103
+ `network: false` for anything you don't fully trust. Without Docker, sandbox
104
+ calls run locally and are flagged `"sandboxed": false`.
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ pip install -e ".[dev,browser]"
110
+ pytest -q
111
+ ```
112
+
113
+ ## License
114
+
115
+ MIT (this plugin). Not affiliated with OpenClaw/ClawHub. See `LICENSE`.
@@ -0,0 +1,113 @@
1
+ """Exolimbs — Hermes plugin.
2
+
3
+ Turns OpenClaw / ClawHub's mature execution substrate (skill registry, local
4
+ sandbox, Playwright browser automation, multi-language runtimes, retry/rollback)
5
+ into a set of structured-JSON tools for Hermes.
6
+
7
+ Design:
8
+ - Hermes is the brain (LLM/conversation/memory/UI).
9
+ - Exolimbs is the hands/feet (deterministic execution). It NEVER calls an
10
+ LLM itself -> zero extra model tokens on the execution path.
11
+ - Two interchangeable backends, switchable via config:
12
+ * "cli" -> shells out to the real `openclaw` / `clawhub` CLIs.
13
+ * "native" -> a decoupled Python re-implementation (no Node dependency).
14
+ * "auto" -> cli if the `openclaw` binary is present, else native.
15
+
16
+ Every tool handler:
17
+ - has signature `handler(args: dict, **kwargs) -> str`
18
+ - ALWAYS returns a JSON string (success and error alike)
19
+ - NEVER raises
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+
26
+ from . import schemas, tools
27
+ from .config import get_settings
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ __version__ = "0.4.0"
32
+
33
+
34
+ def _cli_available() -> bool:
35
+ """check_fn used to hide CLI-only behaviour gracefully (never raises)."""
36
+ try:
37
+ from .backends.cli_backend import openclaw_binary
38
+
39
+ return openclaw_binary() is not None
40
+ except Exception: # pragma: no cover - defensive
41
+ return False
42
+
43
+
44
+ def _tools_available() -> bool:
45
+ """Tools are available whenever a backend can be resolved.
46
+
47
+ The CLI backend needs the `openclaw` binary; the native backend always
48
+ resolves. So tools are available unless the user pinned `backend: cli`
49
+ without installing the CLI.
50
+ """
51
+ try:
52
+ settings = get_settings()
53
+ if settings.backend == "cli":
54
+ return _cli_available()
55
+ return True
56
+ except Exception: # pragma: no cover - defensive
57
+ return True
58
+
59
+
60
+ def register(ctx) -> None:
61
+ """Entry point called once at startup. Wires schemas -> handlers.
62
+
63
+ If this function raises, Hermes disables the plugin but keeps running, so we
64
+ keep it defensive and side-effect-light.
65
+ """
66
+ pairs = (
67
+ ("claw_skill_search", schemas.CLAW_SKILL_SEARCH, tools.claw_skill_search),
68
+ ("claw_skill_install", schemas.CLAW_SKILL_INSTALL, tools.claw_skill_install),
69
+ ("claw_skill_run", schemas.CLAW_SKILL_RUN, tools.claw_skill_run),
70
+ ("claw_sandbox_exec", schemas.CLAW_SANDBOX_EXEC, tools.claw_sandbox_exec),
71
+ ("claw_browser", schemas.CLAW_BROWSER, tools.claw_browser),
72
+ ("claw_runtime", schemas.CLAW_RUNTIME, tools.claw_runtime),
73
+ )
74
+
75
+ for name, schema, handler in pairs:
76
+ ctx.register_tool(
77
+ name=name,
78
+ toolset="exolimbs",
79
+ schema=schema,
80
+ handler=handler,
81
+ check_fn=_tools_available,
82
+ )
83
+
84
+ # Ship an opt-in "how to drive me" skill. Not in the system-prompt index,
85
+ # so it costs zero standing tokens until the agent explicitly loads it.
86
+ try:
87
+ from pathlib import Path
88
+
89
+ skill_md = Path(__file__).parent / "skills" / "exolimbs" / "SKILL.md"
90
+ if skill_md.exists():
91
+ ctx.register_skill("exolimbs", skill_md)
92
+ except Exception as exc: # pragma: no cover - optional
93
+ logger.debug("exolimbs: skill registration skipped: %s", exc)
94
+
95
+ # Diagnostics slash command: /exo status
96
+ try:
97
+ ctx.register_command(
98
+ "exo",
99
+ handler=tools.slash_claw,
100
+ description="Exolimbs status / backend info",
101
+ args_hint="[status|backend|doctor]",
102
+ )
103
+ except Exception as exc: # pragma: no cover - optional
104
+ logger.debug("exolimbs: slash command skipped: %s", exc)
105
+
106
+ s = get_settings()
107
+ logger.info(
108
+ "exolimbs v%s registered (backend=%s, resolved=%s, pro=%s)",
109
+ __version__,
110
+ s.backend,
111
+ s.resolved_backend(),
112
+ s.is_pro(),
113
+ )
@@ -0,0 +1,144 @@
1
+ """Minimal, dependency-free Ed25519 (RFC 8032) for offline license verification.
2
+
3
+ Adapted from the public-domain reference implementation by the Ed25519 authors
4
+ (https://ed25519.cr.yp.to/software.html). Modular exponentiation uses Python's
5
+ built-in ``pow`` for speed. This is used for one-off, offline signature checks
6
+ (license tokens) — it is NOT constant-time and must not be used to sign secrets
7
+ on adversarial shared hardware. Signing happens vendor-side; verification ships
8
+ to customers with zero third-party dependencies.
9
+
10
+ Public API:
11
+ public_from_seed(seed32) -> bytes32
12
+ sign(message, seed32, public_key=None) -> bytes64
13
+ verify(signature64, message, public_key32) -> bool
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+
20
+ _b = 256
21
+ _q = 2**255 - 19
22
+ _l = 2**252 + 27742317777372353535851937790883648493
23
+
24
+
25
+ def _H(m: bytes) -> bytes:
26
+ return hashlib.sha512(m).digest()
27
+
28
+
29
+ def _inv(x: int) -> int:
30
+ return pow(x, _q - 2, _q)
31
+
32
+
33
+ _d = -121665 * _inv(121666) % _q
34
+ _I = pow(2, (_q - 1) // 4, _q)
35
+
36
+
37
+ def _xrecover(y: int) -> int:
38
+ xx = (y * y - 1) * _inv(_d * y * y + 1)
39
+ x = pow(xx, (_q + 3) // 8, _q)
40
+ if (x * x - xx) % _q != 0:
41
+ x = (x * _I) % _q
42
+ if x % 2 != 0:
43
+ x = _q - x
44
+ return x
45
+
46
+
47
+ _By = 4 * _inv(5) % _q
48
+ _Bx = _xrecover(_By)
49
+ _B = [_Bx % _q, _By % _q]
50
+
51
+
52
+ def _edwards(P: list[int], Q: list[int]) -> list[int]:
53
+ x1, y1 = P
54
+ x2, y2 = Q
55
+ x3 = (x1 * y2 + x2 * y1) * _inv(1 + _d * x1 * x2 * y1 * y2)
56
+ y3 = (y1 * y2 + x1 * x2) * _inv(1 - _d * x1 * x2 * y1 * y2)
57
+ return [x3 % _q, y3 % _q]
58
+
59
+
60
+ def _scalarmult(P: list[int], e: int) -> list[int]:
61
+ # Iterative double-and-add (avoids deep recursion).
62
+ result = [0, 1]
63
+ addend = P
64
+ while e > 0:
65
+ if e & 1:
66
+ result = _edwards(result, addend)
67
+ addend = _edwards(addend, addend)
68
+ e >>= 1
69
+ return result
70
+
71
+
72
+ def _bit(h: bytes, i: int) -> int:
73
+ return (h[i // 8] >> (i % 8)) & 1
74
+
75
+
76
+ def _encodeint(y: int) -> bytes:
77
+ return y.to_bytes(_b // 8, "little")
78
+
79
+
80
+ def _encodepoint(P: list[int]) -> bytes:
81
+ x, y = P
82
+ val = (y & ((1 << (_b - 1)) - 1)) | ((x & 1) << (_b - 1))
83
+ return val.to_bytes(_b // 8, "little")
84
+
85
+
86
+ def _decodeint(s: bytes) -> int:
87
+ return int.from_bytes(s, "little")
88
+
89
+
90
+ def _Hint(m: bytes) -> int:
91
+ return _decodeint(_H(m)) % (1 << (2 * _b))
92
+
93
+
94
+ def _secret_scalar(seed: bytes) -> int:
95
+ h = _H(seed)
96
+ return 2 ** (_b - 2) + sum(2**i * _bit(h, i) for i in range(3, _b - 2))
97
+
98
+
99
+ def public_from_seed(seed: bytes) -> bytes:
100
+ if len(seed) != 32:
101
+ raise ValueError("seed must be 32 bytes")
102
+ a = _secret_scalar(seed)
103
+ return _encodepoint(_scalarmult(_B, a))
104
+
105
+
106
+ def sign(message: bytes, seed: bytes, public_key: bytes | None = None) -> bytes:
107
+ if len(seed) != 32:
108
+ raise ValueError("seed must be 32 bytes")
109
+ h = _H(seed)
110
+ a = _secret_scalar(seed)
111
+ pk = public_key if public_key is not None else _encodepoint(_scalarmult(_B, a))
112
+ r = _Hint(h[32:64] + message)
113
+ R = _scalarmult(_B, r)
114
+ S = (r + _Hint(_encodepoint(R) + pk + message) * a) % _l
115
+ return _encodepoint(R) + _encodeint(S)
116
+
117
+
118
+ def _isoncurve(P: list[int]) -> bool:
119
+ x, y = P
120
+ return (-x * x + y * y - 1 - _d * x * x * y * y) % _q == 0
121
+
122
+
123
+ def _decodepoint(s: bytes) -> list[int]:
124
+ y = _decodeint(s) & ((1 << (_b - 1)) - 1)
125
+ x = _xrecover(y)
126
+ if x & 1 != _bit(s, _b - 1):
127
+ x = _q - x
128
+ P = [x, y]
129
+ if not _isoncurve(P):
130
+ raise ValueError("point not on curve")
131
+ return P
132
+
133
+
134
+ def verify(signature: bytes, message: bytes, public_key: bytes) -> bool:
135
+ try:
136
+ if len(signature) != 64 or len(public_key) != 32:
137
+ return False
138
+ R = _decodepoint(signature[:32])
139
+ A = _decodepoint(public_key)
140
+ S = _decodeint(signature[32:64])
141
+ h = _Hint(signature[:32] + public_key + message)
142
+ return _scalarmult(_B, S) == _edwards(R, _scalarmult(A, h))
143
+ except Exception:
144
+ return False
@@ -0,0 +1,132 @@
1
+ """Open-core licensing gate — real offline Ed25519 verification.
2
+
3
+ The free tier ships all six execution tools. Pro features (audit log, curated
4
+ skill packs, auto-update, priority support) require a signed license token.
5
+
6
+ Token format (compact, JWT-like, EdDSA / Ed25519):
7
+
8
+ EXL1.<base64url(payload_json)>.<base64url(signature)>
9
+
10
+ `payload_json` is a UTF-8 JSON object, e.g.:
11
+
12
+ {"sub": "alice@example.com", "tier": "pro", "exp": 1771000000,
13
+ "iat": 1760000000, "jti": "lic_abc123", "features": ["audit", "packs"]}
14
+
15
+ The signature covers the ASCII bytes of `"EXL1." + base64url(payload)` (the
16
+ header+payload, exactly like JWS). Verification is fully offline against the
17
+ embedded public key — no phone-home, works air-gapped. Revoke issued licenses by
18
+ shipping their `jti` in `_REVOKED` with a release.
19
+
20
+ Signing happens vendor-side only (see scripts/issue_license.py). The private seed
21
+ never ships and is git-ignored.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import json
28
+ import time
29
+ from functools import lru_cache
30
+
31
+ from . import _ed25519
32
+
33
+ # Vendor Ed25519 public key (hex, 32 bytes). Replace via scripts/gen_keys.py.
34
+ _PUBLIC_KEY_HEX = "0ac58e8a93d3b09dfb5425c31e4f855dbba6b347cc1e4d001a40ba4aed288490"
35
+
36
+ _PRO_TIERS = frozenset({"pro", "team", "enterprise"})
37
+ _PREFIX = "EXL1"
38
+
39
+ # License IDs (jti) revoked after issuance. Ship updates with releases.
40
+ _REVOKED: frozenset[str] = frozenset()
41
+
42
+
43
+ def _b64url_decode(s: str) -> bytes:
44
+ pad = "=" * (-len(s) % 4)
45
+ return base64.urlsafe_b64decode(s + pad)
46
+
47
+
48
+ def b64url_encode(raw: bytes) -> str:
49
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
50
+
51
+
52
+ def _public_key() -> bytes:
53
+ return bytes.fromhex(_PUBLIC_KEY_HEX)
54
+
55
+
56
+ def decode_license(token: str) -> dict | None:
57
+ """Verify signature + structure. Returns the payload dict or None.
58
+
59
+ Does NOT check expiry/tier/revocation — see `license_status`.
60
+ """
61
+ if not token or not _PUBLIC_KEY_HEX:
62
+ return None
63
+ parts = token.strip().split(".")
64
+ if len(parts) != 3 or parts[0] != _PREFIX:
65
+ return None
66
+ _, payload_b64, sig_b64 = parts
67
+ try:
68
+ signing_input = f"{_PREFIX}.{payload_b64}".encode("ascii")
69
+ signature = _b64url_decode(sig_b64)
70
+ if not _ed25519.verify(signature, signing_input, _public_key()):
71
+ return None
72
+ payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
73
+ return payload if isinstance(payload, dict) else None
74
+ except Exception:
75
+ return None
76
+
77
+
78
+ def license_status(token: str | None) -> dict:
79
+ """Full evaluation -> {valid, tier, reason, ...}. Never raises."""
80
+ import os
81
+
82
+ token = (token or os.environ.get("EXOLIMBS_LICENSE") or "").strip()
83
+ if not token:
84
+ return {"valid": False, "tier": "free", "reason": "no license"}
85
+ payload = decode_license(token)
86
+ if payload is None:
87
+ return {"valid": False, "tier": "free", "reason": "invalid signature"}
88
+ jti = payload.get("jti")
89
+ if jti and jti in _REVOKED:
90
+ return {"valid": False, "tier": "free", "reason": "revoked", "jti": jti}
91
+ exp = payload.get("exp")
92
+ if exp is not None and time.time() > float(exp):
93
+ return {"valid": False, "tier": "free", "reason": "expired", "exp": exp}
94
+ tier = str(payload.get("tier", "")).lower()
95
+ if tier not in _PRO_TIERS:
96
+ return {"valid": False, "tier": tier or "free", "reason": "non-pro tier"}
97
+ return {
98
+ "valid": True,
99
+ "tier": tier,
100
+ "reason": "ok",
101
+ "sub": payload.get("sub"),
102
+ "exp": exp,
103
+ "features": payload.get("features", []),
104
+ "jti": jti,
105
+ }
106
+
107
+
108
+ @lru_cache(maxsize=16)
109
+ def is_pro(key: str | None = None) -> bool:
110
+ return license_status(key)["valid"]
111
+
112
+
113
+ def require_pro(feature: str, key: str | None = None) -> dict | None:
114
+ """Return an error dict if the feature needs Pro and the license is invalid."""
115
+ if is_pro(key):
116
+ return None
117
+ return {
118
+ "ok": False,
119
+ "error": f"'{feature}' requires an Exolimbs Pro license",
120
+ "upgrade": "https://your-store.example.com",
121
+ }
122
+
123
+
124
+ def describe(key: str | None = None) -> str:
125
+ st = license_status(key)
126
+ if st["valid"]:
127
+ exp = st.get("exp")
128
+ when = time.strftime("%Y-%m-%d", time.gmtime(exp)) if exp else "perpetual"
129
+ return f"Pro ({st['tier']}, expires {when})"
130
+ if st["reason"] == "no license":
131
+ return "Free"
132
+ return f"Free (license {st['reason']})"