@agentlayer.tech/wallet 0.1.102 → 0.1.106
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.
- package/.claude-plugin/marketplace.json +4 -1
- package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +1 -1
- package/.openclaw/extensions/agent-wallet/package.json +1 -1
- package/VERSION +1 -1
- package/agent-wallet/README.md +7 -2
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/config.py +15 -1
- package/agent-wallet/agent_wallet/evm_user_wallets.py +101 -244
- package/agent-wallet/agent_wallet/openclaw_runtime.py +7 -2
- package/agent-wallet/agent_wallet/providers/wdk_evm_local.py +40 -9
- package/agent-wallet/agent_wallet/wallet_layer/factory.py +25 -2
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +1 -1
- package/agent-wallet/scripts/bootstrap_openclaw_evm.py +24 -14
- package/agent-wallet/scripts/install_agent_wallet.py +18 -6
- package/agent-wallet/scripts/manage_openclaw_evm_wallet.py +18 -15
- package/agent-wallet/scripts/setup_evm_wallet.sh +2 -3
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +2 -2
- package/claude-code/plugins/agent-wallet/commands/wallet-evm.md +1 -0
- package/claude-code/plugins/agent-wallet/commands/wallet-setup.md +1 -0
- package/claude-code/plugins/agent-wallet/skills/wallet-operator/SKILL.md +1 -0
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/server.py +74 -4
- package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
- package/package.json +1 -1
- package/wdk-btc-wallet/package.json +1 -1
- package/wdk-evm-wallet/README.md +6 -2
- package/wdk-evm-wallet/package.json +3 -2
- package/wdk-evm-wallet/src/config.js +9 -0
- package/wdk-evm-wallet/src/server.js +768 -692
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
"name": "AgentLayer",
|
|
6
6
|
"url": "https://github.com/lopushok9/Agent-Layer"
|
|
7
7
|
},
|
|
8
|
+
"renames": {
|
|
9
|
+
"agent-wallet": "wallet"
|
|
10
|
+
},
|
|
8
11
|
"plugins": [
|
|
9
12
|
{
|
|
10
|
-
"name": "
|
|
13
|
+
"name": "wallet",
|
|
11
14
|
"displayName": "Agent Wallet",
|
|
12
15
|
"description": "Bridge to the local AgentLayer wallet runtime (Solana, Bitcoin, EVM). Installs and connects the backend runtime on first use.",
|
|
13
16
|
"category": "development",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "agent-wallet",
|
|
3
3
|
"name": "Agent Wallet",
|
|
4
4
|
"description": "Official OpenClaw plugin bridge for the agent-wallet backends, including Solana, local BTC, and local EVM.",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.106",
|
|
6
6
|
"contracts": {
|
|
7
7
|
"tools": [
|
|
8
8
|
"agentlayer_autonomous_approve",
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.106
|
package/agent-wallet/README.md
CHANGED
|
@@ -403,14 +403,19 @@ sh agent-wallet/scripts/setup_evm_wallet.sh
|
|
|
403
403
|
That wrapper:
|
|
404
404
|
|
|
405
405
|
- prompts for `user-id` and EVM network when run interactively
|
|
406
|
-
- defaults to
|
|
406
|
+
- defaults to a per-install unix socket at `~/.openclaw/wdk-evm-wallet/daemon.sock`
|
|
407
|
+
(or wherever `OPENCLAW_HOME` points); set `WDK_EVM_SERVICE_URL` to override
|
|
408
|
+
with an explicit `unix://` or `http://` URL for either transport
|
|
407
409
|
- can auto-start `wdk-evm-wallet/run-local.sh` if the local service is not already healthy
|
|
408
410
|
- creates or unlocks the local EVM wallet binding
|
|
409
411
|
- also binds the paired EVM network by default: `ethereum <-> base` (GOAT remains an independent EVM network binding)
|
|
410
412
|
- stores the entered EVM vault password into `sealed_keys.json` when `AGENT_WALLET_BOOT_KEY` is available, so later OpenClaw wallet switching can auto-raise the EVM backend without another password prompt
|
|
411
413
|
- patches OpenClaw config to `backend=wdk_evm_local`
|
|
412
414
|
|
|
413
|
-
Example host-side EVM wallet creation
|
|
415
|
+
Example host-side EVM wallet creation (explicit TCP `wdkEvmServiceUrl` override —
|
|
416
|
+
TCP mode is an opt-in for advanced/remote deployments via `WDK_EVM_TRANSPORT=tcp`
|
|
417
|
+
on the `wdk-evm-wallet` side, and no longer gets the same protection against a
|
|
418
|
+
foreign daemon sharing the port that the unix-socket default gets by construction):
|
|
414
419
|
|
|
415
420
|
```bash
|
|
416
421
|
printf '%s\n' 'your-local-evm-password' | \
|
|
@@ -44,7 +44,7 @@ class Settings(BaseSettings):
|
|
|
44
44
|
wdk_btc_service_url: str = "http://127.0.0.1:8080"
|
|
45
45
|
wdk_btc_wallet_id: str = ""
|
|
46
46
|
wdk_btc_account_index: int = 0
|
|
47
|
-
wdk_evm_service_url: str = "
|
|
47
|
+
wdk_evm_service_url: str = ""
|
|
48
48
|
wdk_evm_wallet_id: str = ""
|
|
49
49
|
wdk_evm_account_index: int = 0
|
|
50
50
|
|
|
@@ -200,6 +200,20 @@ def resolve_openclaw_home() -> Path:
|
|
|
200
200
|
return Path(raw).expanduser()
|
|
201
201
|
|
|
202
202
|
|
|
203
|
+
def resolve_wdk_evm_service_url() -> str:
|
|
204
|
+
"""Resolve the wdk-evm-wallet service URL, unix-socket by default.
|
|
205
|
+
|
|
206
|
+
An explicit WDK_EVM_SERVICE_URL/settings value always wins, whatever
|
|
207
|
+
transport it names (http:// for an explicit TCP deployment, unix:// for
|
|
208
|
+
a non-default socket path). Otherwise this is a per-OPENCLAW_HOME unix
|
|
209
|
+
socket — see docs/superpowers/specs/2026-08-30-evm-wallet-unix-socket-transport-design.md.
|
|
210
|
+
"""
|
|
211
|
+
explicit = settings.wdk_evm_service_url.strip()
|
|
212
|
+
if explicit:
|
|
213
|
+
return explicit
|
|
214
|
+
return f"unix://{resolve_openclaw_home() / 'wdk-evm-wallet' / 'daemon.sock'}"
|
|
215
|
+
|
|
216
|
+
|
|
203
217
|
def default_solana_wallet_path(network: str) -> Path:
|
|
204
218
|
"""Return the default keypair path for a Solana wallet."""
|
|
205
219
|
normalized_network = normalize_solana_network(network)
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import http.client
|
|
5
6
|
import json
|
|
6
7
|
import os
|
|
7
8
|
import secrets
|
|
8
|
-
import shutil
|
|
9
9
|
import signal
|
|
10
|
+
import socket as socket_module
|
|
10
11
|
import subprocess
|
|
11
12
|
import time
|
|
12
13
|
from pathlib import Path
|
|
@@ -20,6 +21,7 @@ from agent_wallet.config import (
|
|
|
20
21
|
resolve_boot_key,
|
|
21
22
|
resolve_evm_wallet_password,
|
|
22
23
|
resolve_openclaw_home,
|
|
24
|
+
resolve_wdk_evm_service_url,
|
|
23
25
|
settings,
|
|
24
26
|
)
|
|
25
27
|
from agent_wallet.file_ops import atomic_write_text
|
|
@@ -28,7 +30,6 @@ from agent_wallet.user_wallets import normalize_user_id
|
|
|
28
30
|
from agent_wallet.wallet_layer.base import WalletBackendError
|
|
29
31
|
|
|
30
32
|
LOCAL_WDK_EVM_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
31
|
-
_SERVICE_OWNER_FILENAME = "service-owner.json"
|
|
32
33
|
_INSTANCE_ID_FILENAME = "instance-id"
|
|
33
34
|
|
|
34
35
|
|
|
@@ -37,7 +38,7 @@ def _normalize_evm_network(value: str | None) -> str:
|
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def _resolve_service_url(service_url: str | None = None) -> str:
|
|
40
|
-
effective = (service_url or
|
|
41
|
+
effective = (service_url or resolve_wdk_evm_service_url()).strip()
|
|
41
42
|
if not effective:
|
|
42
43
|
raise WalletBackendError("wdk_evm_service_url is required for EVM wallet host operations.")
|
|
43
44
|
return effective
|
|
@@ -51,7 +52,22 @@ def _paired_network(network: str) -> str | None:
|
|
|
51
52
|
return mapping.get(_normalize_evm_network(network))
|
|
52
53
|
|
|
53
54
|
|
|
55
|
+
class _UnixHealthConnection(http.client.HTTPConnection):
|
|
56
|
+
"""Minimal HTTPConnection over AF_UNIX, for the unauthenticated /health probe."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, socket_path: str, timeout: float = 1.5):
|
|
59
|
+
super().__init__("localhost", timeout=timeout)
|
|
60
|
+
self._socket_path = socket_path
|
|
61
|
+
|
|
62
|
+
def connect(self) -> None:
|
|
63
|
+
self.sock = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM)
|
|
64
|
+
self.sock.settimeout(self.timeout)
|
|
65
|
+
self.sock.connect(self._socket_path)
|
|
66
|
+
|
|
67
|
+
|
|
54
68
|
def _health_url(service_url: str) -> str:
|
|
69
|
+
# Display/error-message use only now — unix:// targets are fetched via
|
|
70
|
+
# _UnixHealthConnection below, not urlopen, which has no unix-socket support.
|
|
55
71
|
return f"{service_url.rstrip('/')}/health"
|
|
56
72
|
|
|
57
73
|
|
|
@@ -61,12 +77,24 @@ def _service_health(service_url: str) -> dict[str, Any] | None:
|
|
|
61
77
|
An empty dict means the service answered 200 but the body was unparseable —
|
|
62
78
|
treated as "running, version unknown" so we never restart on a parse blip.
|
|
63
79
|
"""
|
|
80
|
+
parsed = urlparse(service_url)
|
|
64
81
|
try:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
82
|
+
if parsed.scheme == "unix":
|
|
83
|
+
conn = _UnixHealthConnection(parsed.path, timeout=1.5)
|
|
84
|
+
try:
|
|
85
|
+
conn.request("GET", "/health")
|
|
86
|
+
response = conn.getresponse()
|
|
87
|
+
if response.status != 200:
|
|
88
|
+
return None
|
|
89
|
+
raw = response.read()
|
|
90
|
+
finally:
|
|
91
|
+
conn.close()
|
|
92
|
+
else:
|
|
93
|
+
with urlopen(_health_url(service_url), timeout=1.5) as response:
|
|
94
|
+
if int(getattr(response, "status", 0) or 0) != 200:
|
|
95
|
+
return None
|
|
96
|
+
raw = response.read()
|
|
97
|
+
except (URLError, TimeoutError, OSError, http.client.HTTPException):
|
|
70
98
|
return None
|
|
71
99
|
try:
|
|
72
100
|
payload = json.loads(raw.decode("utf-8"))
|
|
@@ -109,50 +137,6 @@ def _expected_local_service_instance_id() -> str:
|
|
|
109
137
|
return generated
|
|
110
138
|
|
|
111
139
|
|
|
112
|
-
def _service_owner_path() -> Path:
|
|
113
|
-
return _expected_local_service_data_dir() / _SERVICE_OWNER_FILENAME
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _read_service_owner() -> dict[str, Any] | None:
|
|
117
|
-
try:
|
|
118
|
-
payload = json.loads(_service_owner_path().read_text(encoding="utf-8"))
|
|
119
|
-
except (OSError, ValueError):
|
|
120
|
-
return None
|
|
121
|
-
return payload if isinstance(payload, dict) else None
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def _write_service_owner(health: dict[str, Any], service_url: str) -> int:
|
|
125
|
-
expected_instance = _expected_local_service_instance_id()
|
|
126
|
-
instance_id = str(health.get("instanceId") or "").strip()
|
|
127
|
-
try:
|
|
128
|
-
pid = int(health.get("pid") or 0)
|
|
129
|
-
except (TypeError, ValueError):
|
|
130
|
-
pid = 0
|
|
131
|
-
if instance_id != expected_instance or pid <= 0:
|
|
132
|
-
raise WalletBackendError("wdk-evm-wallet health did not confirm local process ownership.")
|
|
133
|
-
payload = {
|
|
134
|
-
"version": 1,
|
|
135
|
-
"pid": pid,
|
|
136
|
-
"instance_id": instance_id,
|
|
137
|
-
"port": urlparse(service_url).port or 8081,
|
|
138
|
-
"data_dir": str(_expected_local_service_data_dir()),
|
|
139
|
-
"service_version": str(health.get("version") or "").strip() or None,
|
|
140
|
-
}
|
|
141
|
-
atomic_write_text(_service_owner_path(), json.dumps(payload, indent=2) + "\n", mode=0o600)
|
|
142
|
-
return pid
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _same_path(left: str | Path | None, right: str | Path | None) -> bool:
|
|
146
|
-
if left is None or right is None:
|
|
147
|
-
return False
|
|
148
|
-
try:
|
|
149
|
-
left_path = Path(str(left)).expanduser().resolve()
|
|
150
|
-
right_path = Path(str(right)).expanduser().resolve()
|
|
151
|
-
except OSError:
|
|
152
|
-
return False
|
|
153
|
-
return left_path == right_path
|
|
154
|
-
|
|
155
|
-
|
|
156
140
|
def _should_restart_local_service(
|
|
157
141
|
health: dict[str, Any] | None,
|
|
158
142
|
*,
|
|
@@ -162,42 +146,7 @@ def _should_restart_local_service(
|
|
|
162
146
|
return False
|
|
163
147
|
expected_version = _read_on_disk_service_version(wallet_root) if wallet_root is not None else None
|
|
164
148
|
running_version = str(health.get("version") or "").strip()
|
|
165
|
-
|
|
166
|
-
return True
|
|
167
|
-
|
|
168
|
-
reported_data_dir = str(health.get("dataDir") or "").strip()
|
|
169
|
-
if reported_data_dir and not _same_path(reported_data_dir, _expected_local_service_data_dir()):
|
|
170
|
-
return True
|
|
171
|
-
|
|
172
|
-
reported_instance = str(health.get("instanceId") or "").strip()
|
|
173
|
-
if reported_data_dir and reported_instance != _expected_local_service_instance_id():
|
|
174
|
-
return True
|
|
175
|
-
|
|
176
|
-
return False
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def _listening_pids(port: int) -> list[int]:
|
|
180
|
-
"""PIDs LISTENing on a local TCP port (via lsof), excluding our own."""
|
|
181
|
-
lsof = shutil.which("lsof")
|
|
182
|
-
if not lsof:
|
|
183
|
-
return []
|
|
184
|
-
try:
|
|
185
|
-
completed = subprocess.run( # noqa: S603
|
|
186
|
-
[lsof, "-t", "-i", f"tcp:{port}", "-sTCP:LISTEN"],
|
|
187
|
-
capture_output=True,
|
|
188
|
-
text=True,
|
|
189
|
-
timeout=5,
|
|
190
|
-
)
|
|
191
|
-
except (OSError, subprocess.SubprocessError):
|
|
192
|
-
return []
|
|
193
|
-
pids: list[int] = []
|
|
194
|
-
for token in completed.stdout.split():
|
|
195
|
-
token = token.strip()
|
|
196
|
-
if token.isdigit():
|
|
197
|
-
pid = int(token)
|
|
198
|
-
if pid != os.getpid():
|
|
199
|
-
pids.append(pid)
|
|
200
|
-
return pids
|
|
149
|
+
return bool(expected_version and running_version and running_version != expected_version)
|
|
201
150
|
|
|
202
151
|
|
|
203
152
|
def _daemon_takeover_disabled() -> bool:
|
|
@@ -209,40 +158,13 @@ def _daemon_takeover_disabled() -> bool:
|
|
|
209
158
|
}
|
|
210
159
|
|
|
211
160
|
|
|
212
|
-
def _takeover_refusal(
|
|
213
|
-
pids = ", ".join(str(pid) for pid in listeners) if listeners else "unknown"
|
|
161
|
+
def _takeover_refusal(reason: str) -> str:
|
|
214
162
|
return (
|
|
215
|
-
f"Refusing to stop
|
|
216
|
-
|
|
217
|
-
f"To clear it manually: lsof -nP -iTCP:{port} -sTCP:LISTEN, then kill <pid>."
|
|
163
|
+
f"Refusing to stop wdk-evm-wallet: {reason}. "
|
|
164
|
+
"To clear it manually: check /health at the configured service URL for its pid, then kill <pid>."
|
|
218
165
|
)
|
|
219
166
|
|
|
220
167
|
|
|
221
|
-
def _process_cwd(pid: int) -> Path | None:
|
|
222
|
-
"""Return a process working directory via lsof, or None when unverified."""
|
|
223
|
-
lsof = shutil.which("lsof")
|
|
224
|
-
if not lsof:
|
|
225
|
-
return None
|
|
226
|
-
try:
|
|
227
|
-
completed = subprocess.run( # noqa: S603
|
|
228
|
-
[lsof, "-a", "-p", str(pid), "-d", "cwd", "-Fn"],
|
|
229
|
-
capture_output=True,
|
|
230
|
-
text=True,
|
|
231
|
-
timeout=5,
|
|
232
|
-
)
|
|
233
|
-
except (OSError, subprocess.SubprocessError):
|
|
234
|
-
return None
|
|
235
|
-
if completed.returncode != 0:
|
|
236
|
-
return None
|
|
237
|
-
for line in completed.stdout.splitlines():
|
|
238
|
-
if line.startswith("n") and line[1:].strip():
|
|
239
|
-
try:
|
|
240
|
-
return Path(line[1:].strip()).resolve()
|
|
241
|
-
except OSError:
|
|
242
|
-
return None
|
|
243
|
-
return None
|
|
244
|
-
|
|
245
|
-
|
|
246
168
|
def _process_exists(pid: int) -> bool:
|
|
247
169
|
try:
|
|
248
170
|
os.kill(pid, 0)
|
|
@@ -253,129 +175,52 @@ def _process_exists(pid: int) -> bool:
|
|
|
253
175
|
return True
|
|
254
176
|
|
|
255
177
|
|
|
256
|
-
def _process_released_service(pid: int,
|
|
178
|
+
def _process_released_service(pid: int, service_url: str) -> bool:
|
|
257
179
|
if not _process_exists(pid):
|
|
258
180
|
return True
|
|
259
|
-
# A daemon signalled by a process other than its parent can remain
|
|
260
|
-
# as a zombie. Once its
|
|
261
|
-
#
|
|
262
|
-
return
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def _owner_matches(
|
|
266
|
-
owner: dict[str, Any] | None,
|
|
267
|
-
current_health: dict[str, Any],
|
|
268
|
-
*,
|
|
269
|
-
pid: int,
|
|
270
|
-
port: int,
|
|
271
|
-
) -> bool:
|
|
272
|
-
if owner is None:
|
|
273
|
-
return not _service_owner_path().exists()
|
|
274
|
-
try:
|
|
275
|
-
owner_pid = int(owner.get("pid") or 0)
|
|
276
|
-
owner_port = int(owner.get("port") or 0)
|
|
277
|
-
except (TypeError, ValueError):
|
|
278
|
-
return False
|
|
279
|
-
return (
|
|
280
|
-
owner_pid == pid
|
|
281
|
-
and owner_port == port
|
|
282
|
-
and _same_path(owner.get("data_dir"), _expected_local_service_data_dir())
|
|
283
|
-
and str(owner.get("instance_id") or "")
|
|
284
|
-
== str(current_health.get("instanceId") or "")
|
|
285
|
-
)
|
|
181
|
+
# A daemon signalled by a process other than its parent can remain
|
|
182
|
+
# briefly as a zombie. Once its health endpoint is gone it no longer
|
|
183
|
+
# blocks a fresh daemon from claiming the socket.
|
|
184
|
+
return _service_health(service_url) is None
|
|
286
185
|
|
|
287
186
|
|
|
288
|
-
def _resolve_stoppable_pid(current_health: dict[str, Any]
|
|
289
|
-
"""Return
|
|
290
|
-
reported_data_dir = str(current_health.get("dataDir") or "").strip()
|
|
291
|
-
if not _same_path(reported_data_dir, _expected_local_service_data_dir()):
|
|
292
|
-
return 0
|
|
293
|
-
listeners = _listening_pids(port)
|
|
294
|
-
if not listeners:
|
|
295
|
-
return 0
|
|
187
|
+
def _resolve_stoppable_pid(current_health: dict[str, Any]) -> int:
|
|
188
|
+
"""Return the daemon's reported PID, or 0 if it didn't report one."""
|
|
296
189
|
try:
|
|
297
|
-
|
|
190
|
+
pid = int(current_health.get("pid") or 0)
|
|
298
191
|
except (TypeError, ValueError):
|
|
299
|
-
reported_pid = 0
|
|
300
|
-
if reported_pid > 0:
|
|
301
|
-
if reported_pid not in listeners:
|
|
302
|
-
return 0
|
|
303
|
-
candidate = reported_pid
|
|
304
|
-
elif len(listeners) == 1:
|
|
305
|
-
# One-time compatibility for a pre-PID daemon. The listener and its
|
|
306
|
-
# working directory still have to identify the bundled service.
|
|
307
|
-
candidate = listeners[0]
|
|
308
|
-
else:
|
|
309
|
-
return 0
|
|
310
|
-
cwd = _process_cwd(candidate)
|
|
311
|
-
if cwd is None or cwd.name != "wdk-evm-wallet":
|
|
312
|
-
return 0
|
|
313
|
-
if not _owner_matches(_read_service_owner(), current_health, pid=candidate, port=port):
|
|
314
192
|
return 0
|
|
315
|
-
return
|
|
193
|
+
return pid if pid > 0 else 0
|
|
316
194
|
|
|
317
195
|
|
|
318
196
|
def _stop_local_service(service_url: str, health: dict[str, Any] | None = None) -> None:
|
|
319
197
|
"""Gracefully stop a local wdk-evm-wallet daemon so a fresh one can start.
|
|
320
198
|
|
|
321
|
-
SIGTERM the listener
|
|
199
|
+
SIGTERM the listener, wait for /health to drop, then SIGKILL as a fallback.
|
|
322
200
|
"""
|
|
323
201
|
port = urlparse(service_url).port or 8081
|
|
324
202
|
current_health = health if health is not None else _service_health(service_url)
|
|
325
203
|
if not current_health or current_health.get("service") != "wdk-evm-wallet":
|
|
326
|
-
raise WalletBackendError(
|
|
327
|
-
f"Refusing to stop an unidentified service on port {port}."
|
|
328
|
-
)
|
|
329
|
-
listeners = _listening_pids(port)
|
|
330
|
-
reported_data_dir = str(current_health.get("dataDir") or "").strip()
|
|
204
|
+
raise WalletBackendError(f"Refusing to stop an unidentified service on port {port}.")
|
|
331
205
|
|
|
332
206
|
if _daemon_takeover_disabled():
|
|
333
207
|
raise WalletBackendError(
|
|
334
|
-
_takeover_refusal(
|
|
335
|
-
port,
|
|
336
|
-
listeners,
|
|
337
|
-
reported_data_dir,
|
|
338
|
-
"takeover is disabled by OPENCLAW_EVM_DISABLE_DAEMON_TAKEOVER",
|
|
339
|
-
)
|
|
208
|
+
_takeover_refusal("takeover is disabled by OPENCLAW_EVM_DISABLE_DAEMON_TAKEOVER")
|
|
340
209
|
)
|
|
341
210
|
|
|
342
|
-
|
|
343
|
-
raise WalletBackendError(
|
|
344
|
-
_takeover_refusal(
|
|
345
|
-
port,
|
|
346
|
-
listeners,
|
|
347
|
-
reported_data_dir,
|
|
348
|
-
"the daemon belongs to a different wallet home",
|
|
349
|
-
)
|
|
350
|
-
)
|
|
351
|
-
|
|
352
|
-
owned_pid = _resolve_stoppable_pid(current_health, port)
|
|
211
|
+
owned_pid = _resolve_stoppable_pid(current_health)
|
|
353
212
|
if owned_pid <= 0:
|
|
354
213
|
raise WalletBackendError(
|
|
355
|
-
_takeover_refusal(
|
|
356
|
-
port,
|
|
357
|
-
listeners,
|
|
358
|
-
reported_data_dir,
|
|
359
|
-
"the listening process could not be identified",
|
|
360
|
-
)
|
|
214
|
+
_takeover_refusal("the daemon's /health response did not include a pid")
|
|
361
215
|
)
|
|
362
216
|
|
|
363
217
|
try:
|
|
364
218
|
os.kill(owned_pid, 0)
|
|
365
219
|
except ProcessLookupError:
|
|
366
|
-
try:
|
|
367
|
-
_service_owner_path().unlink()
|
|
368
|
-
except FileNotFoundError:
|
|
369
|
-
pass
|
|
370
220
|
return
|
|
371
221
|
except PermissionError as exc:
|
|
372
222
|
raise WalletBackendError(
|
|
373
|
-
_takeover_refusal(
|
|
374
|
-
port,
|
|
375
|
-
listeners,
|
|
376
|
-
reported_data_dir,
|
|
377
|
-
f"pid {owned_pid} belongs to another user ({exc})",
|
|
378
|
-
)
|
|
223
|
+
_takeover_refusal(f"pid {owned_pid} belongs to another user ({exc})")
|
|
379
224
|
) from exc
|
|
380
225
|
|
|
381
226
|
try:
|
|
@@ -383,45 +228,31 @@ def _stop_local_service(service_url: str, health: dict[str, Any] | None = None)
|
|
|
383
228
|
except ProcessLookupError:
|
|
384
229
|
pass
|
|
385
230
|
except PermissionError as exc:
|
|
386
|
-
raise WalletBackendError(
|
|
387
|
-
|
|
388
|
-
) from exc
|
|
231
|
+
raise WalletBackendError(f"Cannot stop stale wdk-evm-wallet (pid {owned_pid}): {exc}.") from exc
|
|
232
|
+
|
|
389
233
|
deadline = time.time() + 10.0
|
|
390
234
|
while time.time() < deadline:
|
|
391
|
-
if _process_released_service(owned_pid,
|
|
392
|
-
try:
|
|
393
|
-
_service_owner_path().unlink()
|
|
394
|
-
except FileNotFoundError:
|
|
395
|
-
pass
|
|
235
|
+
if _process_released_service(owned_pid, service_url):
|
|
396
236
|
return
|
|
397
237
|
time.sleep(0.3)
|
|
398
|
-
|
|
399
|
-
#
|
|
238
|
+
|
|
239
|
+
# Re-check immediately before a hard stop. A PID that exited and was
|
|
240
|
+
# reused must never inherit permission from stale health.
|
|
400
241
|
refreshed_health = _service_health(service_url)
|
|
401
242
|
if (
|
|
402
243
|
not refreshed_health
|
|
403
244
|
or refreshed_health.get("service") != "wdk-evm-wallet"
|
|
404
|
-
or _resolve_stoppable_pid(refreshed_health
|
|
245
|
+
or _resolve_stoppable_pid(refreshed_health) != owned_pid
|
|
405
246
|
):
|
|
406
|
-
raise WalletBackendError(
|
|
407
|
-
|
|
408
|
-
port,
|
|
409
|
-
_listening_pids(port),
|
|
410
|
-
str((refreshed_health or {}).get("dataDir") or ""),
|
|
411
|
-
"process identity changed before the hard stop",
|
|
412
|
-
)
|
|
413
|
-
)
|
|
247
|
+
raise WalletBackendError(_takeover_refusal("process identity changed before the hard stop"))
|
|
248
|
+
|
|
414
249
|
try:
|
|
415
250
|
os.kill(owned_pid, signal.SIGKILL)
|
|
416
251
|
except ProcessLookupError:
|
|
417
252
|
pass
|
|
418
253
|
deadline = time.time() + 5.0
|
|
419
254
|
while time.time() < deadline:
|
|
420
|
-
if _process_released_service(owned_pid,
|
|
421
|
-
try:
|
|
422
|
-
_service_owner_path().unlink()
|
|
423
|
-
except FileNotFoundError:
|
|
424
|
-
pass
|
|
255
|
+
if _process_released_service(owned_pid, service_url):
|
|
425
256
|
return
|
|
426
257
|
time.sleep(0.3)
|
|
427
258
|
raise WalletBackendError(f"Failed to stop stale wdk-evm-wallet on port {port}.")
|
|
@@ -429,6 +260,11 @@ def _stop_local_service(service_url: str, health: dict[str, Any] | None = None)
|
|
|
429
260
|
|
|
430
261
|
def _is_local_service_url(service_url: str) -> bool:
|
|
431
262
|
parsed = urlparse(service_url)
|
|
263
|
+
if parsed.scheme == "unix":
|
|
264
|
+
# A unix socket is local by construction. Mirrors the same acceptance in
|
|
265
|
+
# scripts/bootstrap_openclaw_evm.py and providers/wdk_evm_local.py; without
|
|
266
|
+
# it _auto_start_local_service refuses the default service URL outright.
|
|
267
|
+
return bool(parsed.path)
|
|
432
268
|
return parsed.scheme in {"http", "https"} and parsed.hostname in LOCAL_WDK_EVM_HOSTS
|
|
433
269
|
|
|
434
270
|
|
|
@@ -453,16 +289,14 @@ def _auto_start_local_service(service_url: str, network: str) -> None:
|
|
|
453
289
|
health = _service_health(service_url)
|
|
454
290
|
if health is not None:
|
|
455
291
|
# Already running. The daemon loads code once at boot (no hot-reload), so a
|
|
456
|
-
# long-running process keeps serving stale code after a release.
|
|
457
|
-
#
|
|
458
|
-
#
|
|
459
|
-
#
|
|
460
|
-
#
|
|
292
|
+
# long-running process keeps serving stale code after a release. Restart
|
|
293
|
+
# only when the local daemon no longer matches the expected launcher
|
|
294
|
+
# version — its socket path is scoped to this dataDir, so nothing foreign
|
|
295
|
+
# can answer here. Remote (non-local) healthy services we don't manage are
|
|
296
|
+
# left untouched.
|
|
461
297
|
if not _is_local_service_url(service_url):
|
|
462
298
|
return
|
|
463
299
|
if not _should_restart_local_service(health, wallet_root=wallet_root):
|
|
464
|
-
if str(health.get("instanceId") or "").strip():
|
|
465
|
-
_write_service_owner(health, service_url)
|
|
466
300
|
return
|
|
467
301
|
_stop_local_service(service_url, health)
|
|
468
302
|
if not _is_local_service_url(service_url):
|
|
@@ -475,8 +309,13 @@ def _auto_start_local_service(service_url: str, network: str) -> None:
|
|
|
475
309
|
)
|
|
476
310
|
parsed = urlparse(service_url)
|
|
477
311
|
env = os.environ.copy()
|
|
478
|
-
|
|
479
|
-
|
|
312
|
+
if parsed.scheme == "unix":
|
|
313
|
+
env["WDK_EVM_TRANSPORT"] = "socket"
|
|
314
|
+
env["WDK_EVM_SOCKET_PATH"] = parsed.path
|
|
315
|
+
else:
|
|
316
|
+
env["WDK_EVM_TRANSPORT"] = "tcp"
|
|
317
|
+
env["HOST"] = parsed.hostname or "127.0.0.1"
|
|
318
|
+
env["PORT"] = str(parsed.port or 8081)
|
|
480
319
|
env["WDK_EVM_NETWORK"] = _normalize_evm_network(network)
|
|
481
320
|
env["WDK_EVM_INSTANCE_ID"] = _expected_local_service_instance_id()
|
|
482
321
|
process = subprocess.Popen( # noqa: S603
|
|
@@ -492,7 +331,6 @@ def _auto_start_local_service(service_url: str, network: str) -> None:
|
|
|
492
331
|
while time.time() < deadline:
|
|
493
332
|
health = _service_health(service_url)
|
|
494
333
|
if health is not None:
|
|
495
|
-
_write_service_owner(health, service_url)
|
|
496
334
|
return
|
|
497
335
|
if process.poll() is not None:
|
|
498
336
|
raise WalletBackendError("wdk-evm-wallet exited before becoming healthy.")
|
|
@@ -502,6 +340,25 @@ def _auto_start_local_service(service_url: str, network: str) -> None:
|
|
|
502
340
|
)
|
|
503
341
|
|
|
504
342
|
|
|
343
|
+
def ensure_local_evm_service_ready(service_url: str, network: str) -> None:
|
|
344
|
+
"""Public entry point for callers outside the OpenClaw user-wallet flow.
|
|
345
|
+
|
|
346
|
+
Thin wrapper around `_auto_start_local_service`, which was previously only
|
|
347
|
+
reachable through `ensure_user_evm_wallet_ready`/`resolve_user_evm_wallet_binding`
|
|
348
|
+
(the multi-user OpenClaw gateway/Hermes path). The single-agent factory
|
|
349
|
+
(`wallet_layer.factory.create_wallet_backend`) has no user_id and doesn't
|
|
350
|
+
need one — the underlying ownership/health checks are already keyed off
|
|
351
|
+
`service_url`/`OPENCLAW_HOME`, not the caller's user. Exposing this lets
|
|
352
|
+
both paths share one recovery implementation instead of drifting apart.
|
|
353
|
+
|
|
354
|
+
Raises `WalletBackendError` for anything the auto-start/eviction logic
|
|
355
|
+
can't resolve on its own (e.g. a foreign-home daemon occupying the port);
|
|
356
|
+
callers should let that surface as a clear tool error rather than a raw
|
|
357
|
+
connection failure.
|
|
358
|
+
"""
|
|
359
|
+
_auto_start_local_service(service_url, network)
|
|
360
|
+
|
|
361
|
+
|
|
505
362
|
def _resolve_user_evm_wallet_dir(user_id: str) -> Path:
|
|
506
363
|
return resolve_openclaw_home() / "users" / normalize_user_id(user_id) / "wallets"
|
|
507
364
|
|
|
@@ -8,7 +8,12 @@ from typing import Any
|
|
|
8
8
|
from agent_wallet.approval import issue_approval_token
|
|
9
9
|
from agent_wallet.boot_key_migration import migrate_boot_key_to_keystore
|
|
10
10
|
from agent_wallet.btc_user_wallets import get_user_btc_wallet_binding
|
|
11
|
-
from agent_wallet.config import
|
|
11
|
+
from agent_wallet.config import (
|
|
12
|
+
normalize_btc_network,
|
|
13
|
+
normalize_evm_network,
|
|
14
|
+
resolve_wdk_evm_service_url,
|
|
15
|
+
settings,
|
|
16
|
+
)
|
|
12
17
|
from agent_wallet.evm_user_wallets import ensure_user_evm_wallet_ready
|
|
13
18
|
from agent_wallet.models import OpenClawWalletSessionMetadata
|
|
14
19
|
from agent_wallet.openclaw_adapter import OpenClawWalletAdapter
|
|
@@ -181,7 +186,7 @@ def onboard_openclaw_user_wallet(
|
|
|
181
186
|
)
|
|
182
187
|
|
|
183
188
|
if backend_name in {"wdk_evm_local", "wdk-evm-local", "evm_local", "evm-local"}:
|
|
184
|
-
service_url = str(wdk_evm_service_url or
|
|
189
|
+
service_url = str(wdk_evm_service_url or resolve_wdk_evm_service_url()).strip()
|
|
185
190
|
account_index = (
|
|
186
191
|
settings.wdk_evm_account_index
|
|
187
192
|
if wdk_evm_account_index is None
|