clawmes 0.1.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.
- clawmes/__init__.py +142 -0
- clawmes/_version.py +10 -0
- clawmes/bridges/__init__.py +24 -0
- clawmes/bridges/installer.py +126 -0
- clawmes/bridges/process.py +263 -0
- clawmes/bridges/sa_client.py +96 -0
- clawmes/bridges/sources/wc/package-lock.json +1130 -0
- clawmes/bridges/sources/wc/package.json +22 -0
- clawmes/bridges/sources/wc/src/index.ts +137 -0
- clawmes/bridges/sources/wc/src/methods.ts +247 -0
- clawmes/bridges/sources/wc/src/wc-client.ts +63 -0
- clawmes/bridges/sources/wc/tsconfig.json +18 -0
- clawmes/bridges/wc_client.py +77 -0
- clawmes/cli/__init__.py +45 -0
- clawmes/cli/_argparse.py +159 -0
- clawmes/cli/doctor.py +151 -0
- clawmes/cli/init.py +338 -0
- clawmes/cli/version.py +21 -0
- clawmes/commands/__init__.py +40 -0
- clawmes/commands/doctor.py +315 -0
- clawmes/commands/help.py +96 -0
- clawmes/commands/plans.py +67 -0
- clawmes/commands/policy.py +121 -0
- clawmes/commands/tx.py +160 -0
- clawmes/commands/wallet.py +193 -0
- clawmes/data/SOUL.md +61 -0
- clawmes/data/__init__.py +2 -0
- clawmes/data/personas/chill.md +9 -0
- clawmes/data/personas/degen.md +9 -0
- clawmes/data/personas/mentor.md +9 -0
- clawmes/data/personas/professional.md +9 -0
- clawmes/data/personas/technical.md +9 -0
- clawmes/hooks/__init__.py +39 -0
- clawmes/hooks/after_tool_call.py +132 -0
- clawmes/hooks/on_session.py +39 -0
- clawmes/hooks/pre_gateway_dispatch.py +51 -0
- clawmes/hooks/pre_tool_call.py +32 -0
- clawmes/hooks/prompt_builder.py +101 -0
- clawmes/hooks/subagent_stop.py +39 -0
- clawmes/hooks/transform_terminal_output.py +33 -0
- clawmes/hooks/transform_tool_result.py +42 -0
- clawmes/ledger/__init__.py +14 -0
- clawmes/ledger/tx_ledger.py +128 -0
- clawmes/lib/__init__.py +6 -0
- clawmes/lib/abi.py +137 -0
- clawmes/lib/addr.py +72 -0
- clawmes/lib/chains.py +140 -0
- clawmes/lib/decimals.py +74 -0
- clawmes/lib/ens.py +164 -0
- clawmes/lib/http.py +196 -0
- clawmes/lib/logger.py +72 -0
- clawmes/lib/params.py +104 -0
- clawmes/lib/paths.py +91 -0
- clawmes/lib/time.py +95 -0
- clawmes/lib/tool_result.py +69 -0
- clawmes/onboarding/__init__.py +22 -0
- clawmes/onboarding/flow.py +35 -0
- clawmes/onboarding/personas.py +60 -0
- clawmes/persona.py +74 -0
- clawmes/plans/__init__.py +32 -0
- clawmes/plans/compiler.py +28 -0
- clawmes/plans/executor.py +32 -0
- clawmes/plans/ir.py +78 -0
- clawmes/plans/scheduler.py +131 -0
- clawmes/plans/triggers/__init__.py +8 -0
- clawmes/plans/triggers/price_trigger.py +44 -0
- clawmes/plans/triggers/time_trigger.py +33 -0
- clawmes/plans/validator.py +103 -0
- clawmes/plugin.yaml +92 -0
- clawmes/policy/__init__.py +36 -0
- clawmes/policy/confirm_store.py +82 -0
- clawmes/policy/evaluator.py +93 -0
- clawmes/policy/parser.py +269 -0
- clawmes/policy/storage.py +109 -0
- clawmes/policy/types.py +117 -0
- clawmes/policy/usage_counter.py +72 -0
- clawmes/services/__init__.py +111 -0
- clawmes/services/_base.py +57 -0
- clawmes/services/aave.py +171 -0
- clawmes/services/bankr_service.py +216 -0
- clawmes/services/coingecko.py +170 -0
- clawmes/services/credential_redactor.py +280 -0
- clawmes/services/explorer.py +226 -0
- clawmes/services/governance.py +125 -0
- clawmes/services/lifi.py +185 -0
- clawmes/services/mode_service.py +75 -0
- clawmes/services/persona_service.py +85 -0
- clawmes/services/price.py +114 -0
- clawmes/services/registry.py +72 -0
- clawmes/services/rpc.py +327 -0
- clawmes/services/safe.py +140 -0
- clawmes/services/staking.py +102 -0
- clawmes/services/token_decimals.py +216 -0
- clawmes/services/wallet.py +210 -0
- clawmes/services/wc_notifications.py +130 -0
- clawmes/services/zerox.py +243 -0
- clawmes/skills/__init__.py +63 -0
- clawmes/skills/agent-memory/SKILL.md +63 -0
- clawmes/skills/airdrop/SKILL.md +85 -0
- clawmes/skills/analytics/SKILL.md +109 -0
- clawmes/skills/approvals/SKILL.md +88 -0
- clawmes/skills/automation/SKILL.md +84 -0
- clawmes/skills/bankr/SKILL.md +99 -0
- clawmes/skills/block-explorer/SKILL.md +66 -0
- clawmes/skills/bridge/SKILL.md +70 -0
- clawmes/skills/browser/SKILL.md +62 -0
- clawmes/skills/cost-basis/SKILL.md +61 -0
- clawmes/skills/defi-swap/SKILL.md +89 -0
- clawmes/skills/defi-trading/SKILL.md +67 -0
- clawmes/skills/farcaster/SKILL.md +87 -0
- clawmes/skills/governance/SKILL.md +97 -0
- clawmes/skills/lending/SKILL.md +74 -0
- clawmes/skills/liquidity/SKILL.md +130 -0
- clawmes/skills/manage-orders/SKILL.md +66 -0
- clawmes/skills/market-intel/SKILL.md +67 -0
- clawmes/skills/nft/SKILL.md +95 -0
- clawmes/skills/permit2/SKILL.md +96 -0
- clawmes/skills/privacy/SKILL.md +60 -0
- clawmes/skills/safe-multisig/SKILL.md +99 -0
- clawmes/skills/session-recall/SKILL.md +62 -0
- clawmes/skills/skill-evolve/SKILL.md +68 -0
- clawmes/skills/staking/SKILL.md +70 -0
- clawmes/skills/transfer/SKILL.md +67 -0
- clawmes/skills/watch-activity/SKILL.md +84 -0
- clawmes/tools/__init__.py +128 -0
- clawmes/tools/_user_tools.py +121 -0
- clawmes/tools/agent_memory.py +106 -0
- clawmes/tools/airdrop.py +259 -0
- clawmes/tools/analytics.py +384 -0
- clawmes/tools/approvals.py +364 -0
- clawmes/tools/bankr_automate.py +90 -0
- clawmes/tools/bankr_launch.py +101 -0
- clawmes/tools/bankr_leverage.py +92 -0
- clawmes/tools/bankr_polymarket.py +83 -0
- clawmes/tools/block_explorer.py +163 -0
- clawmes/tools/bridge.py +375 -0
- clawmes/tools/browser.py +148 -0
- clawmes/tools/clawnch_fees.py +125 -0
- clawmes/tools/clawnch_launch.py +152 -0
- clawmes/tools/clawnchconnect.py +203 -0
- clawmes/tools/clawnx.py +100 -0
- clawmes/tools/compound_action.py +107 -0
- clawmes/tools/cost_basis.py +351 -0
- clawmes/tools/defi_balance.py +244 -0
- clawmes/tools/defi_lend.py +330 -0
- clawmes/tools/defi_price.py +125 -0
- clawmes/tools/defi_stake.py +220 -0
- clawmes/tools/defi_swap.py +460 -0
- clawmes/tools/farcaster.py +215 -0
- clawmes/tools/giza.py +103 -0
- clawmes/tools/governance.py +268 -0
- clawmes/tools/herd_intelligence.py +112 -0
- clawmes/tools/hummingbot.py +99 -0
- clawmes/tools/liquidity.py +413 -0
- clawmes/tools/lobster_cash.py +105 -0
- clawmes/tools/manage_orders.py +157 -0
- clawmes/tools/market_intel.py +170 -0
- clawmes/tools/molten.py +104 -0
- clawmes/tools/nft.py +374 -0
- clawmes/tools/nookplot.py +92 -0
- clawmes/tools/paysponge.py +111 -0
- clawmes/tools/permit2.py +317 -0
- clawmes/tools/privacy.py +112 -0
- clawmes/tools/registry.py +270 -0
- clawmes/tools/safe.py +228 -0
- clawmes/tools/session_recall.py +135 -0
- clawmes/tools/skill_evolve.py +140 -0
- clawmes/tools/transfer.py +761 -0
- clawmes/tools/watch_activity.py +199 -0
- clawmes/tools/wayfinder.py +94 -0
- clawmes/tools/yield_farming.py +196 -0
- clawmes/wallet/__init__.py +25 -0
- clawmes/wallet/_base.py +75 -0
- clawmes/wallet/bankr.py +165 -0
- clawmes/wallet/keystore.py +313 -0
- clawmes/wallet/local_key.py +309 -0
- clawmes/wallet/state.py +64 -0
- clawmes/wallet/walletconnect.py +223 -0
- clawmes-0.1.0.dist-info/METADATA +314 -0
- clawmes-0.1.0.dist-info/RECORD +184 -0
- clawmes-0.1.0.dist-info/WHEEL +5 -0
- clawmes-0.1.0.dist-info/entry_points.txt +2 -0
- clawmes-0.1.0.dist-info/licenses/LICENSE +21 -0
- clawmes-0.1.0.dist-info/top_level.txt +1 -0
clawmes/__init__.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Clawmes — Hermes Agent for crypto.
|
|
2
|
+
|
|
3
|
+
A crypto-native plugin for Hermes Agent. Registers tools, commands, hooks,
|
|
4
|
+
skills, and CLI subcommands via Hermes' standard ``register(ctx)`` plugin
|
|
5
|
+
contract.
|
|
6
|
+
|
|
7
|
+
The ``register`` function is the single entry point invoked by
|
|
8
|
+
``hermes_cli.plugins.PluginManager`` at process startup. It is sync and must
|
|
9
|
+
return quickly. Heavy work (RPC warmup, key validation) is deferred to first
|
|
10
|
+
use or to background threads started by ``services.start_all()``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
# When Hermes loads us via ``importlib.util.spec_from_file_location``
|
|
16
|
+
# (e.g. ``hermes plugins install <git>`` path), the module name is a
|
|
17
|
+
# namespaced ``hermes_plugins.clawmes`` rather than the bare
|
|
18
|
+
# ``clawmes``. The rest of this package uses absolute imports
|
|
19
|
+
# (``from clawmes.X import Y``) — without aliasing, those would fail
|
|
20
|
+
# with ``cannot import name 'X' from 'clawmes' (unknown location)``.
|
|
21
|
+
#
|
|
22
|
+
# Aliasing here makes the namespaced module also reachable as
|
|
23
|
+
# ``clawmes``, so subsequent absolute imports resolve to THIS same
|
|
24
|
+
# module instance (no double-load, singletons stay singleton). When
|
|
25
|
+
# loaded via ``pip install`` (canonical bare name from the start),
|
|
26
|
+
# the alias check sees ``clawmes`` already present and skips.
|
|
27
|
+
import sys as _sys
|
|
28
|
+
|
|
29
|
+
if "clawmes" not in _sys.modules:
|
|
30
|
+
_sys.modules["clawmes"] = _sys.modules[__name__]
|
|
31
|
+
|
|
32
|
+
import atexit
|
|
33
|
+
import signal
|
|
34
|
+
|
|
35
|
+
from clawmes import (
|
|
36
|
+
cli,
|
|
37
|
+
commands,
|
|
38
|
+
hooks,
|
|
39
|
+
persona,
|
|
40
|
+
services,
|
|
41
|
+
skills,
|
|
42
|
+
tools,
|
|
43
|
+
)
|
|
44
|
+
from clawmes._version import __version__
|
|
45
|
+
from clawmes.lib.logger import logger_for
|
|
46
|
+
|
|
47
|
+
__all__ = ["__version__", "register"]
|
|
48
|
+
|
|
49
|
+
_log = logger_for("plugin")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def register(ctx) -> None:
|
|
53
|
+
"""Plugin entry point called by Hermes at startup.
|
|
54
|
+
|
|
55
|
+
Stages, in order:
|
|
56
|
+
|
|
57
|
+
1. **Idempotent first-run setup.** ``persona.ensure_soul_md()`` copies
|
|
58
|
+
the bundled SOUL.md into ``${HERMES_HOME}/SOUL.md`` if absent. Never
|
|
59
|
+
overwrites a user-edited file.
|
|
60
|
+
2. **Surface registration.** Tools, commands, hooks, skills, and CLI
|
|
61
|
+
subcommands are wired through the corresponding ``register_all(ctx)``
|
|
62
|
+
calls. Each subsystem's stub is filled in across subsequent
|
|
63
|
+
milestones; calling them now is a no-op except for ``tools`` once
|
|
64
|
+
individual tool modules land.
|
|
65
|
+
3. **Service start.** ``services.start_all()`` brings up wallet, RPC,
|
|
66
|
+
price, plan-scheduler, etc. in topological order.
|
|
67
|
+
4. **Cleanup hooks.** ``atexit`` + SIGTERM/SIGINT handlers ensure
|
|
68
|
+
``services.stop_all()`` runs on graceful shutdown.
|
|
69
|
+
|
|
70
|
+
Errors during ``register()`` are caught and logged so a clawmes failure
|
|
71
|
+
never crashes Hermes' boot — the plugin manager will mark the plugin
|
|
72
|
+
disabled and surface the error in ``hermes plugins list``.
|
|
73
|
+
"""
|
|
74
|
+
_log.info("clawmes %s registering with Hermes", __version__)
|
|
75
|
+
|
|
76
|
+
# 1. First-run setup. If this fails we still continue to surface
|
|
77
|
+
# registration — a missing SOUL.md is a degraded state, not a hard
|
|
78
|
+
# failure.
|
|
79
|
+
_safe("persona.ensure_soul_md", persona.ensure_soul_md)
|
|
80
|
+
|
|
81
|
+
# 2. Register surface — each subsystem is isolated so a buggy
|
|
82
|
+
# commands module can't take down tools, hooks, skills, or the CLI.
|
|
83
|
+
# Hermes shows a partial-feature plugin instead of a fully-disabled
|
|
84
|
+
# one.
|
|
85
|
+
_safe("tools.register_all", tools.register_all, ctx)
|
|
86
|
+
_safe("commands.register_all", commands.register_all, ctx)
|
|
87
|
+
_safe("hooks.register_all", hooks.register_all, ctx)
|
|
88
|
+
_safe("skills.register_all", skills.register_all, ctx)
|
|
89
|
+
_safe("cli.register_all", cli.register_all, ctx)
|
|
90
|
+
|
|
91
|
+
# 3. Background services. start_all itself wraps each service in
|
|
92
|
+
# try/except, so partial failure here means partial feature loss
|
|
93
|
+
# rather than total plugin failure.
|
|
94
|
+
_safe("services.start_all", services.start_all)
|
|
95
|
+
|
|
96
|
+
# 4. Cleanup
|
|
97
|
+
try:
|
|
98
|
+
atexit.register(services.stop_all)
|
|
99
|
+
_install_signal_handlers()
|
|
100
|
+
except Exception:
|
|
101
|
+
_log.exception("failed to install cleanup hooks")
|
|
102
|
+
|
|
103
|
+
_log.info("clawmes register() complete")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _safe(label: str, fn, *args, **kwargs) -> None:
|
|
107
|
+
"""Run ``fn(*args, **kwargs)`` and log any exception without re-raising.
|
|
108
|
+
|
|
109
|
+
Used to isolate register-time failures: a broken module in one
|
|
110
|
+
subsystem (e.g. ``commands``) shouldn't disable every other
|
|
111
|
+
subsystem. If something fails here the user gets a partially-
|
|
112
|
+
functional plugin and a clear log entry instead of a fully-
|
|
113
|
+
disabled plugin and a stack trace in ``hermes plugins list``.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
fn(*args, **kwargs)
|
|
117
|
+
except Exception:
|
|
118
|
+
_log.exception("clawmes register: %s failed; continuing", label)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _install_signal_handlers() -> None:
|
|
122
|
+
"""Attach SIGTERM/SIGINT handlers for clean service shutdown.
|
|
123
|
+
|
|
124
|
+
Falls through to whatever Hermes already installed — we re-raise after
|
|
125
|
+
stopping our own services so the parent's handlers still fire.
|
|
126
|
+
"""
|
|
127
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
128
|
+
prev = signal.getsignal(sig)
|
|
129
|
+
|
|
130
|
+
def _handler(signum, frame, _prev=prev):
|
|
131
|
+
try:
|
|
132
|
+
services.stop_all()
|
|
133
|
+
finally:
|
|
134
|
+
if callable(_prev) and _prev not in (signal.SIG_DFL, signal.SIG_IGN):
|
|
135
|
+
_prev(signum, frame)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
signal.signal(sig, _handler)
|
|
139
|
+
except ValueError:
|
|
140
|
+
# Signal handlers can only be set in the main thread — Hermes
|
|
141
|
+
# may have already done this, fine to skip.
|
|
142
|
+
pass
|
clawmes/_version.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Single-source-of-truth version string.
|
|
2
|
+
|
|
3
|
+
Read by:
|
|
4
|
+
* ``clawmes/__init__.py`` — exposed as ``clawmes.__version__``
|
|
5
|
+
* ``clawmes/cli/version.py`` — emitted by ``hermes clawmes version``
|
|
6
|
+
* ``hermes clawmes doctor`` — reported in diagnostics output
|
|
7
|
+
* Tooling that does not want to incur a full package import
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Node sub-process bridges.
|
|
2
|
+
|
|
3
|
+
Two long-lived Node processes are spawned at plugin start:
|
|
4
|
+
|
|
5
|
+
* ``clawmes-wc-bridge`` — wraps ``@walletconnect/sign-client`` for WC v2
|
|
6
|
+
pairing, signing, and session management.
|
|
7
|
+
* ``clawmes-sa-bridge`` — wraps ``@metamask/smart-accounts-kit`` for
|
|
8
|
+
EIP-7702/7710 delegation creation, listing, and execution.
|
|
9
|
+
|
|
10
|
+
Why subprocess? Both upstream JS libs are first-party reference impls
|
|
11
|
+
of in-flux specs; re-implementing in Python is a 6-month project per
|
|
12
|
+
bridge with continuous spec drift. Sub-process keeps the spec-tracking
|
|
13
|
+
burden on upstream.
|
|
14
|
+
|
|
15
|
+
Wire format: JSON-line over stdio. See PRD §21 for the full method
|
|
16
|
+
catalog and protocol.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from clawmes.bridges.installer import ensure_node_bridges
|
|
22
|
+
from clawmes.bridges.process import BridgeProcess
|
|
23
|
+
|
|
24
|
+
__all__ = ["BridgeProcess", "ensure_node_bridges"]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Bridge installer.
|
|
2
|
+
|
|
3
|
+
On every plugin start we make sure the Node bridge sources are present
|
|
4
|
+
and ``npm ci``-installed under ``${HERMES_HOME}/clawmes/bridges/{wc,sa}``.
|
|
5
|
+
|
|
6
|
+
Strategy:
|
|
7
|
+
|
|
8
|
+
1. Locate the bundled source under
|
|
9
|
+
``clawmes/bridges/sources/{wc,sa}/``.
|
|
10
|
+
2. Hash ``package.json`` + ``package-lock.json``.
|
|
11
|
+
3. Compare against ``${HERMES_HOME}/clawmes/bridges/{wc,sa}/.installed-hash``.
|
|
12
|
+
4. If changed (or absent):
|
|
13
|
+
a. Copy the bundled source into the install directory
|
|
14
|
+
b. Run ``npm ci --omit=dev``
|
|
15
|
+
c. Write the hash file
|
|
16
|
+
5. Return absolute paths to ``dist/index.mjs`` for each bridge.
|
|
17
|
+
|
|
18
|
+
If Node is not installed, we log a clear warning and return None — the
|
|
19
|
+
plugin still loads, but tools that need bridges will fail with a
|
|
20
|
+
friendly error.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from clawmes.lib.logger import logger_for
|
|
32
|
+
from clawmes.lib.paths import bridges_dir
|
|
33
|
+
|
|
34
|
+
_log = logger_for("bridges.installer")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_SOURCES_ROOT = Path(__file__).parent / "sources"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class BridgePaths:
|
|
42
|
+
wc_entry: Path | None
|
|
43
|
+
sa_entry: Path | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ensure_node_bridges(*, force: bool = False) -> BridgePaths:
|
|
47
|
+
"""Idempotent installer for both bridges. Safe to call on every boot."""
|
|
48
|
+
node = shutil.which("node")
|
|
49
|
+
if not node:
|
|
50
|
+
_log.warning(
|
|
51
|
+
"Node.js not found — install Node ≥ 20 for clawmes wallet bridges. "
|
|
52
|
+
"Plugin will load but WC + SA tools will fail."
|
|
53
|
+
)
|
|
54
|
+
return BridgePaths(wc_entry=None, sa_entry=None)
|
|
55
|
+
|
|
56
|
+
wc_entry = _ensure_one("wc", force=force)
|
|
57
|
+
sa_entry = _ensure_one("sa", force=force)
|
|
58
|
+
return BridgePaths(wc_entry=wc_entry, sa_entry=sa_entry)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ensure_one(name: str, *, force: bool) -> Path | None:
|
|
62
|
+
src = _SOURCES_ROOT / name
|
|
63
|
+
if not src.exists():
|
|
64
|
+
_log.debug("bridge source for %s missing at %s — not yet bundled", name, src)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
target = bridges_dir() / name
|
|
68
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
pj = src / "package.json"
|
|
71
|
+
pl = src / "package-lock.json"
|
|
72
|
+
if not pj.exists() or not pl.exists():
|
|
73
|
+
_log.debug("bridge source for %s lacks package files yet — skipping install", name)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
expected_hash = _hash_files(pj, pl)
|
|
77
|
+
marker = target / ".installed-hash"
|
|
78
|
+
if (
|
|
79
|
+
not force
|
|
80
|
+
and marker.exists()
|
|
81
|
+
and marker.read_text(encoding="utf-8").strip() == expected_hash
|
|
82
|
+
):
|
|
83
|
+
_log.debug("bridge %s already installed (hash %s)", name, expected_hash[:8])
|
|
84
|
+
return target / "dist" / "index.mjs"
|
|
85
|
+
|
|
86
|
+
_log.info("installing bridge %s …", name)
|
|
87
|
+
_copy_tree(src, target)
|
|
88
|
+
try:
|
|
89
|
+
subprocess.run(
|
|
90
|
+
["npm", "ci", "--omit=dev"],
|
|
91
|
+
cwd=target,
|
|
92
|
+
check=True,
|
|
93
|
+
capture_output=True,
|
|
94
|
+
text=True,
|
|
95
|
+
)
|
|
96
|
+
except subprocess.CalledProcessError as exc:
|
|
97
|
+
_log.error("npm ci failed for %s: %s", name, exc.stderr)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
marker.write_text(expected_hash + "\n", encoding="utf-8")
|
|
101
|
+
return target / "dist" / "index.mjs"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _hash_files(*paths: Path) -> str:
|
|
105
|
+
h = hashlib.sha256()
|
|
106
|
+
for p in paths:
|
|
107
|
+
h.update(p.read_bytes())
|
|
108
|
+
return h.hexdigest()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _copy_tree(src: Path, dst: Path) -> None:
|
|
112
|
+
"""Mirror ``src`` into ``dst``, preserving file mtimes."""
|
|
113
|
+
if dst.exists():
|
|
114
|
+
# Don't blow away node_modules; just refresh source files.
|
|
115
|
+
for child in src.iterdir():
|
|
116
|
+
if child.name == "node_modules":
|
|
117
|
+
continue
|
|
118
|
+
target = dst / child.name
|
|
119
|
+
if child.is_dir():
|
|
120
|
+
if target.exists():
|
|
121
|
+
shutil.rmtree(target)
|
|
122
|
+
shutil.copytree(child, target)
|
|
123
|
+
else:
|
|
124
|
+
shutil.copy2(child, target)
|
|
125
|
+
else:
|
|
126
|
+
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("node_modules"))
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""``BridgeProcess`` — long-lived Node subprocess with JSON-line RPC.
|
|
2
|
+
|
|
3
|
+
Manages spawn, stdio reader / writer threads, future-based request /
|
|
4
|
+
response matching, notification dispatch, and graceful shutdown. Per-
|
|
5
|
+
bridge clients (``wc_client``, ``sa_client``) wrap typed methods on
|
|
6
|
+
top of this.
|
|
7
|
+
|
|
8
|
+
Wire format (one record per line, terminated with ``\\n``):
|
|
9
|
+
|
|
10
|
+
Request: ``{"id": "<uuid>", "method": "<name>", "params": {...}}``
|
|
11
|
+
Response OK: ``{"id": "<uuid>", "result": <any>}``
|
|
12
|
+
Response err: ``{"id": "<uuid>", "error": {"code": "<str>", ...}}``
|
|
13
|
+
Notification: ``{"method": "<event>", "params": {...}}`` (no id)
|
|
14
|
+
|
|
15
|
+
Concurrency model:
|
|
16
|
+
|
|
17
|
+
* One **reader thread** per process — pulls lines from stdout,
|
|
18
|
+
parses, dispatches to either the matching pending future
|
|
19
|
+
(response) or the notification queue.
|
|
20
|
+
* The **caller** writes to stdin under a lock, then waits on a
|
|
21
|
+
``threading.Event`` until the reader resolves it.
|
|
22
|
+
* No event loop — callers can be sync or async; the bridge blocks
|
|
23
|
+
until response arrives or the timeout fires.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
import threading
|
|
32
|
+
import uuid
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from queue import Queue
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from clawmes.lib.logger import logger_for
|
|
39
|
+
|
|
40
|
+
_log = logger_for("bridges.process")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BridgeError(RuntimeError):
|
|
44
|
+
"""Raised when a bridge call fails (crash, timeout, RPC error)."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, code: str, message: str, *, data: Any = None) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.code = code
|
|
49
|
+
self.data = data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Notification:
|
|
54
|
+
"""Server-pushed event from a bridge (no ``id`` field)."""
|
|
55
|
+
|
|
56
|
+
method: str
|
|
57
|
+
params: dict[str, Any]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class _PendingCall:
|
|
62
|
+
event: threading.Event = field(default_factory=threading.Event)
|
|
63
|
+
result: Any = None
|
|
64
|
+
error: BridgeError | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BridgeProcess:
|
|
68
|
+
"""Lifecycle wrapper around a single Node bridge subprocess."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
name: str,
|
|
73
|
+
entry: Path,
|
|
74
|
+
*,
|
|
75
|
+
node_bin: str | None = None,
|
|
76
|
+
env: dict[str, str] | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.name = name
|
|
79
|
+
self.entry = entry
|
|
80
|
+
self._node_bin = node_bin or shutil.which("node") or "node"
|
|
81
|
+
self._env = env
|
|
82
|
+
self._proc: subprocess.Popen | None = None
|
|
83
|
+
self._reader: threading.Thread | None = None
|
|
84
|
+
self._stderr_reader: threading.Thread | None = None
|
|
85
|
+
self._writer_lock = threading.Lock()
|
|
86
|
+
self._pending: dict[str, _PendingCall] = {}
|
|
87
|
+
self._pending_lock = threading.Lock()
|
|
88
|
+
self._notifications: Queue[Notification] = Queue()
|
|
89
|
+
self._lock = threading.RLock()
|
|
90
|
+
self._stopping = False
|
|
91
|
+
|
|
92
|
+
# ----- lifecycle ----------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def start(self) -> None:
|
|
95
|
+
with self._lock:
|
|
96
|
+
if self._proc is not None and self._proc.poll() is None:
|
|
97
|
+
return
|
|
98
|
+
_log.info("starting bridge %s: %s %s", self.name, self._node_bin, self.entry)
|
|
99
|
+
self._stopping = False
|
|
100
|
+
self._proc = subprocess.Popen(
|
|
101
|
+
[self._node_bin, str(self.entry)],
|
|
102
|
+
stdin=subprocess.PIPE,
|
|
103
|
+
stdout=subprocess.PIPE,
|
|
104
|
+
stderr=subprocess.PIPE,
|
|
105
|
+
bufsize=1, # line-buffered
|
|
106
|
+
text=True,
|
|
107
|
+
env=self._env,
|
|
108
|
+
)
|
|
109
|
+
self._reader = threading.Thread(
|
|
110
|
+
target=self._read_stdout,
|
|
111
|
+
name=f"{self.name}-reader",
|
|
112
|
+
daemon=True,
|
|
113
|
+
)
|
|
114
|
+
self._reader.start()
|
|
115
|
+
self._stderr_reader = threading.Thread(
|
|
116
|
+
target=self._read_stderr,
|
|
117
|
+
name=f"{self.name}-stderr",
|
|
118
|
+
daemon=True,
|
|
119
|
+
)
|
|
120
|
+
self._stderr_reader.start()
|
|
121
|
+
|
|
122
|
+
def stop(self) -> None:
|
|
123
|
+
with self._lock:
|
|
124
|
+
self._stopping = True
|
|
125
|
+
if self._proc is None:
|
|
126
|
+
return
|
|
127
|
+
if self._proc.poll() is None:
|
|
128
|
+
try:
|
|
129
|
+
self._proc.terminate()
|
|
130
|
+
self._proc.wait(timeout=5)
|
|
131
|
+
except subprocess.TimeoutExpired:
|
|
132
|
+
self._proc.kill()
|
|
133
|
+
try:
|
|
134
|
+
self._proc.wait(timeout=2)
|
|
135
|
+
except subprocess.TimeoutExpired:
|
|
136
|
+
pass
|
|
137
|
+
# Fail every pending call with a clean error
|
|
138
|
+
with self._pending_lock:
|
|
139
|
+
for call in self._pending.values():
|
|
140
|
+
call.error = BridgeError("bridge_stopped", f"bridge {self.name} stopped")
|
|
141
|
+
call.event.set()
|
|
142
|
+
self._pending.clear()
|
|
143
|
+
self._proc = None
|
|
144
|
+
|
|
145
|
+
def is_running(self) -> bool:
|
|
146
|
+
return self._proc is not None and self._proc.poll() is None
|
|
147
|
+
|
|
148
|
+
# ----- request / response ------------------------------------------
|
|
149
|
+
|
|
150
|
+
def call(self, method: str, params: dict[str, Any], *, timeout: float = 30.0) -> Any:
|
|
151
|
+
"""Send a request, wait for the matching response."""
|
|
152
|
+
if not self.is_running():
|
|
153
|
+
raise BridgeError("not_running", f"bridge {self.name} is not running")
|
|
154
|
+
|
|
155
|
+
request_id = str(uuid.uuid4())
|
|
156
|
+
call = _PendingCall()
|
|
157
|
+
with self._pending_lock:
|
|
158
|
+
self._pending[request_id] = call
|
|
159
|
+
|
|
160
|
+
line = json.dumps({"id": request_id, "method": method, "params": params})
|
|
161
|
+
try:
|
|
162
|
+
with self._writer_lock:
|
|
163
|
+
assert self._proc is not None and self._proc.stdin is not None
|
|
164
|
+
self._proc.stdin.write(line + "\n")
|
|
165
|
+
self._proc.stdin.flush()
|
|
166
|
+
except (BrokenPipeError, OSError) as exc:
|
|
167
|
+
with self._pending_lock:
|
|
168
|
+
self._pending.pop(request_id, None)
|
|
169
|
+
raise BridgeError(
|
|
170
|
+
"write_failed", f"bridge {self.name} stdin write failed: {exc}"
|
|
171
|
+
) from exc
|
|
172
|
+
|
|
173
|
+
if not call.event.wait(timeout):
|
|
174
|
+
with self._pending_lock:
|
|
175
|
+
self._pending.pop(request_id, None)
|
|
176
|
+
raise BridgeError(
|
|
177
|
+
"timeout", f"bridge {self.name} timeout after {timeout}s on {method!r}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if call.error is not None:
|
|
181
|
+
raise call.error
|
|
182
|
+
return call.result
|
|
183
|
+
|
|
184
|
+
def notifications(self) -> Queue[Notification]:
|
|
185
|
+
return self._notifications
|
|
186
|
+
|
|
187
|
+
# ----- internals ---------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def _read_stdout(self) -> None:
|
|
190
|
+
"""Reader thread — parses each line, routes to pending future or notification queue."""
|
|
191
|
+
proc = self._proc
|
|
192
|
+
if proc is None or proc.stdout is None:
|
|
193
|
+
return
|
|
194
|
+
for raw in iter(proc.stdout.readline, ""):
|
|
195
|
+
line = raw.strip()
|
|
196
|
+
if not line:
|
|
197
|
+
continue
|
|
198
|
+
try:
|
|
199
|
+
payload = json.loads(line)
|
|
200
|
+
except json.JSONDecodeError:
|
|
201
|
+
_log.warning("%s: malformed line: %r", self.name, line[:200])
|
|
202
|
+
continue
|
|
203
|
+
self._handle_payload(payload)
|
|
204
|
+
# Reader exiting — process closed stdout. If we have pending
|
|
205
|
+
# calls and we're not in a stop() pathway, surface that as an
|
|
206
|
+
# error.
|
|
207
|
+
if not self._stopping:
|
|
208
|
+
with self._pending_lock:
|
|
209
|
+
pending = list(self._pending.items())
|
|
210
|
+
self._pending.clear()
|
|
211
|
+
for request_id, call in pending:
|
|
212
|
+
call.error = BridgeError(
|
|
213
|
+
"bridge_crashed",
|
|
214
|
+
f"bridge {self.name} stdout closed with pending request {request_id}",
|
|
215
|
+
)
|
|
216
|
+
call.event.set()
|
|
217
|
+
|
|
218
|
+
def _read_stderr(self) -> None:
|
|
219
|
+
"""Stderr drain — tag every line with the bridge name and forward to logger."""
|
|
220
|
+
proc = self._proc
|
|
221
|
+
if proc is None or proc.stderr is None:
|
|
222
|
+
return
|
|
223
|
+
for raw in iter(proc.stderr.readline, ""):
|
|
224
|
+
line = raw.strip()
|
|
225
|
+
if line:
|
|
226
|
+
_log.warning("%s [stderr]: %s", self.name, line)
|
|
227
|
+
|
|
228
|
+
def _handle_payload(self, payload: Any) -> None:
|
|
229
|
+
if not isinstance(payload, dict):
|
|
230
|
+
_log.warning("%s: non-dict payload: %r", self.name, payload)
|
|
231
|
+
return
|
|
232
|
+
request_id = payload.get("id")
|
|
233
|
+
if request_id is None:
|
|
234
|
+
self._enqueue_notification(payload)
|
|
235
|
+
return
|
|
236
|
+
with self._pending_lock:
|
|
237
|
+
call = self._pending.pop(str(request_id), None)
|
|
238
|
+
if call is None:
|
|
239
|
+
_log.debug("%s: response for unknown id %r", self.name, request_id)
|
|
240
|
+
return
|
|
241
|
+
if "error" in payload and payload["error"]:
|
|
242
|
+
err = payload["error"]
|
|
243
|
+
if isinstance(err, dict):
|
|
244
|
+
call.error = BridgeError(
|
|
245
|
+
code=str(err.get("code", "unknown")),
|
|
246
|
+
message=str(err.get("message", "")),
|
|
247
|
+
data=err.get("data"),
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
call.error = BridgeError("unknown", str(err))
|
|
251
|
+
else:
|
|
252
|
+
call.result = payload.get("result")
|
|
253
|
+
call.event.set()
|
|
254
|
+
|
|
255
|
+
def _enqueue_notification(self, payload: dict[str, Any]) -> None:
|
|
256
|
+
method = payload.get("method")
|
|
257
|
+
if not isinstance(method, str):
|
|
258
|
+
_log.warning("%s: notification missing method: %r", self.name, payload)
|
|
259
|
+
return
|
|
260
|
+
params = payload.get("params") or {}
|
|
261
|
+
if not isinstance(params, dict):
|
|
262
|
+
params = {}
|
|
263
|
+
self._notifications.put(Notification(method=method, params=params))
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Python client for ``clawmes-sa-bridge`` (MetaMask Smart Accounts).
|
|
2
|
+
|
|
3
|
+
Typed wrappers for EIP-7702 / 7710 delegation work and Permit2 signing.
|
|
4
|
+
Methods (see PRD §21.3):
|
|
5
|
+
|
|
6
|
+
* :meth:`delegation_create`
|
|
7
|
+
* :meth:`delegation_list`
|
|
8
|
+
* :meth:`delegation_revoke`
|
|
9
|
+
* :meth:`delegation_execute` — used by the ``@write_tool`` gating
|
|
10
|
+
pipeline as stage 3 (skip handler if delegation handles it)
|
|
11
|
+
* :meth:`account_deploy`
|
|
12
|
+
* :meth:`permit2_sign`
|
|
13
|
+
* :meth:`health`
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from clawmes.bridges.process import BridgeProcess
|
|
22
|
+
from clawmes.lib.logger import logger_for
|
|
23
|
+
|
|
24
|
+
_log = logger_for("bridges.sa")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SmartAccountsClient:
|
|
28
|
+
def __init__(self, entry: Path, *, node_bin: str = "node") -> None:
|
|
29
|
+
self._proc = BridgeProcess("clawmes-sa", entry, node_bin=node_bin)
|
|
30
|
+
|
|
31
|
+
def start(self) -> None:
|
|
32
|
+
self._proc.start()
|
|
33
|
+
|
|
34
|
+
def stop(self) -> None:
|
|
35
|
+
self._proc.stop()
|
|
36
|
+
|
|
37
|
+
def delegation_create(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
delegate: str,
|
|
41
|
+
permissions: list[dict[str, Any]],
|
|
42
|
+
expiry: int,
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
return self._proc.call(
|
|
45
|
+
"delegation_create",
|
|
46
|
+
{"delegate": delegate, "permissions": permissions, "expiry": expiry},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def delegation_list(self) -> list[dict[str, Any]]:
|
|
50
|
+
result = self._proc.call("delegation_list", {})
|
|
51
|
+
return list(result.get("delegations", []))
|
|
52
|
+
|
|
53
|
+
def delegation_revoke(self, delegation_id: str) -> str:
|
|
54
|
+
result = self._proc.call("delegation_revoke", {"delegation_id": delegation_id})
|
|
55
|
+
return result["tx_hash"]
|
|
56
|
+
|
|
57
|
+
def delegation_execute(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
delegation_id: str,
|
|
61
|
+
calldata: str,
|
|
62
|
+
to: str,
|
|
63
|
+
value: str = "0x0",
|
|
64
|
+
chain_id: int,
|
|
65
|
+
) -> str:
|
|
66
|
+
result = self._proc.call(
|
|
67
|
+
"delegation_execute",
|
|
68
|
+
{
|
|
69
|
+
"delegation_id": delegation_id,
|
|
70
|
+
"calldata": calldata,
|
|
71
|
+
"to": to,
|
|
72
|
+
"value": value,
|
|
73
|
+
"chain_id": chain_id,
|
|
74
|
+
},
|
|
75
|
+
timeout=60.0,
|
|
76
|
+
)
|
|
77
|
+
return result["tx_hash"]
|
|
78
|
+
|
|
79
|
+
def account_deploy(self, chain_id: int) -> dict[str, Any]:
|
|
80
|
+
return self._proc.call("account_deploy", {"chain_id": chain_id})
|
|
81
|
+
|
|
82
|
+
def permit2_sign(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
token: str,
|
|
86
|
+
spender: str,
|
|
87
|
+
amount: str,
|
|
88
|
+
deadline: int,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
return self._proc.call(
|
|
91
|
+
"permit2_sign",
|
|
92
|
+
{"token": token, "spender": spender, "amount": amount, "deadline": deadline},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def health(self) -> dict[str, Any]:
|
|
96
|
+
return self._proc.call("health", {})
|