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 +113 -0
- exolimbs/_ed25519.py +144 -0
- exolimbs/_licensing.py +132 -0
- exolimbs/_retry.py +64 -0
- exolimbs/backends/__init__.py +42 -0
- exolimbs/backends/base.py +52 -0
- exolimbs/backends/cli_backend.py +156 -0
- exolimbs/backends/native_backend.py +521 -0
- exolimbs/config.py +156 -0
- exolimbs/plugin.yaml +27 -0
- exolimbs/schemas.py +159 -0
- exolimbs/skills/exolimbs/SKILL.md +58 -0
- exolimbs/tools.py +207 -0
- exolimbs-0.4.0.dist-info/METADATA +139 -0
- exolimbs-0.4.0.dist-info/RECORD +19 -0
- exolimbs-0.4.0.dist-info/WHEEL +5 -0
- exolimbs-0.4.0.dist-info/entry_points.txt +2 -0
- exolimbs-0.4.0.dist-info/licenses/LICENSE +32 -0
- exolimbs-0.4.0.dist-info/top_level.txt +1 -0
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]: ...
|