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 +32 -0
- exolimbs-0.4.0/PKG-INFO +139 -0
- exolimbs-0.4.0/README.md +115 -0
- exolimbs-0.4.0/exolimbs/__init__.py +113 -0
- exolimbs-0.4.0/exolimbs/_ed25519.py +144 -0
- exolimbs-0.4.0/exolimbs/_licensing.py +132 -0
- exolimbs-0.4.0/exolimbs/_retry.py +64 -0
- exolimbs-0.4.0/exolimbs/backends/__init__.py +42 -0
- exolimbs-0.4.0/exolimbs/backends/base.py +52 -0
- exolimbs-0.4.0/exolimbs/backends/cli_backend.py +156 -0
- exolimbs-0.4.0/exolimbs/backends/native_backend.py +521 -0
- exolimbs-0.4.0/exolimbs/config.py +156 -0
- exolimbs-0.4.0/exolimbs/plugin.yaml +27 -0
- exolimbs-0.4.0/exolimbs/schemas.py +159 -0
- exolimbs-0.4.0/exolimbs/skills/exolimbs/SKILL.md +58 -0
- exolimbs-0.4.0/exolimbs/tools.py +207 -0
- exolimbs-0.4.0/exolimbs.egg-info/PKG-INFO +139 -0
- exolimbs-0.4.0/exolimbs.egg-info/SOURCES.txt +23 -0
- exolimbs-0.4.0/exolimbs.egg-info/dependency_links.txt +1 -0
- exolimbs-0.4.0/exolimbs.egg-info/entry_points.txt +2 -0
- exolimbs-0.4.0/exolimbs.egg-info/requires.txt +7 -0
- exolimbs-0.4.0/exolimbs.egg-info/top_level.txt +1 -0
- exolimbs-0.4.0/pyproject.toml +41 -0
- exolimbs-0.4.0/setup.cfg +4 -0
- exolimbs-0.4.0/tests/test_exolimbs.py +275 -0
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.
|
exolimbs-0.4.0/PKG-INFO
ADDED
|
@@ -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`.
|
exolimbs-0.4.0/README.md
ADDED
|
@@ -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']})"
|