exolimbs 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.
exolimbs/__init__.py ADDED
@@ -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
+ )
exolimbs/_ed25519.py ADDED
@@ -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
exolimbs/_licensing.py ADDED
@@ -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']})"
exolimbs/_retry.py ADDED
@@ -0,0 +1,64 @@
1
+ """Retry + rollback helper.
2
+
3
+ `with_retry` runs an operation that returns a result dict. It retries on
4
+ *recoverable* failures (exceptions, or dicts with a truthy `retryable` flag /
5
+ network-ish errors) up to `retries` times with exponential backoff. The operation
6
+ itself is responsible for being safe to retry — pass `retries=0` for
7
+ non-idempotent operations.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import time
14
+ from typing import Any, Callable
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _RETRYABLE_HINTS = ("timeout", "unreachable", "temporarily", "connection", "reset", "502", "503", "504")
19
+
20
+
21
+ def _looks_retryable(result: Any) -> bool:
22
+ if not isinstance(result, dict):
23
+ return False
24
+ if result.get("ok"):
25
+ return False
26
+ if result.get("retryable") is True:
27
+ return True
28
+ err = str(result.get("error", "")).lower()
29
+ return any(h in err for h in _RETRYABLE_HINTS)
30
+
31
+
32
+ def with_retry(
33
+ op: Callable[[], dict],
34
+ *,
35
+ retries: int = 2,
36
+ backoff_s: float = 1.0,
37
+ rollback: bool = True,
38
+ ) -> dict:
39
+ attempt = 0
40
+ last: dict = {"ok": False, "error": "not executed"}
41
+ while True:
42
+ try:
43
+ result = op()
44
+ if isinstance(result, dict) and result.get("ok"):
45
+ if attempt:
46
+ result.setdefault("attempts", attempt + 1)
47
+ return result
48
+ last = result if isinstance(result, dict) else {"ok": False, "error": str(result)}
49
+ if not _looks_retryable(last) or attempt >= retries:
50
+ if rollback and not last.get("ok"):
51
+ last.setdefault("rolled_back", True)
52
+ last.setdefault("attempts", attempt + 1)
53
+ return last
54
+ except Exception as exc: # treat as retryable transient
55
+ last = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
56
+ if attempt >= retries:
57
+ if rollback:
58
+ last.setdefault("rolled_back", True)
59
+ last.setdefault("attempts", attempt + 1)
60
+ return last
61
+ sleep_for = backoff_s * (2**attempt)
62
+ logger.debug("with_retry: attempt %s failed, sleeping %.1fs", attempt + 1, sleep_for)
63
+ time.sleep(min(sleep_for, 30.0))
64
+ attempt += 1
@@ -0,0 +1,42 @@
1
+ """Backend resolver — picks CLI vs native per config and caches the instance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+
7
+ from ..config import get_settings
8
+ from .base import Backend
9
+
10
+ _lock = threading.Lock()
11
+ _cache: dict[str, Backend] = {}
12
+
13
+
14
+ def get_backend() -> Backend:
15
+ """Resolve the active backend (thread-safe, cached by resolved name)."""
16
+ resolved = get_settings().resolved_backend()
17
+ cached = _cache.get(resolved)
18
+ if cached is not None:
19
+ return cached
20
+ with _lock:
21
+ cached = _cache.get(resolved)
22
+ if cached is not None:
23
+ return cached
24
+ if resolved == "cli":
25
+ from .cli_backend import CliBackend
26
+
27
+ backend: Backend = CliBackend()
28
+ else:
29
+ from .native_backend import NativeBackend
30
+
31
+ backend = NativeBackend()
32
+ _cache[resolved] = backend
33
+ return backend
34
+
35
+
36
+ def reset_backend() -> None:
37
+ """Drop cached backends (tests / config reload)."""
38
+ with _lock:
39
+ _cache.clear()
40
+
41
+
42
+ __all__ = ["Backend", "get_backend", "reset_backend"]
@@ -0,0 +1,52 @@
1
+ """Backend abstract base class.
2
+
3
+ A backend is the deterministic execution substrate. Two implementations:
4
+ - CliBackend : shells out to `openclaw` / `clawhub`.
5
+ - NativeBackend : decoupled Python re-implementation (no Node dependency).
6
+
7
+ All methods return a plain dict (NOT a JSON string); tools.py handles
8
+ serialization, retry, rollback and audit. Methods may raise — the caller
9
+ wraps them. Prefer returning {"ok": False, "error": ...} for expected
10
+ failures and raising only for unexpected ones.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import abc
16
+ from typing import Any
17
+
18
+
19
+ class Backend(abc.ABC):
20
+ name: str = "base"
21
+
22
+ # -- skill registry ----------------------------------------------------
23
+ @abc.abstractmethod
24
+ def skill_search(self, *, query: str, limit: int) -> dict[str, Any]: ...
25
+
26
+ @abc.abstractmethod
27
+ def skill_install(
28
+ self, *, slug: str, verify: bool, global_install: bool
29
+ ) -> dict[str, Any]: ...
30
+
31
+ @abc.abstractmethod
32
+ def skill_run(
33
+ self, *, slug: str, entry: str, args: dict, sandbox: bool
34
+ ) -> dict[str, Any]: ...
35
+
36
+ # -- execution ---------------------------------------------------------
37
+ @abc.abstractmethod
38
+ def sandbox_exec(
39
+ self,
40
+ *,
41
+ command: str,
42
+ image: str,
43
+ timeout_s: int,
44
+ network: bool,
45
+ workdir: str | None,
46
+ ) -> dict[str, Any]: ...
47
+
48
+ @abc.abstractmethod
49
+ def browser(self, *, actions: list[dict], headless: bool) -> dict[str, Any]: ...
50
+
51
+ @abc.abstractmethod
52
+ def runtime(self, *, lang: str, code: str, timeout_s: int) -> dict[str, Any]: ...