@agentlayer.tech/wallet 0.1.91 → 0.1.93
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/.openclaw/extensions/agent-wallet/openclaw.plugin.json +1 -1
- package/.openclaw/extensions/agent-wallet/package.json +1 -1
- package/CHANGELOG.md +38 -0
- package/README.md +86 -490
- package/VERSION +1 -1
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/boot_key_migration.py +10 -21
- package/agent-wallet/agent_wallet/config.py +20 -0
- package/agent-wallet/agent_wallet/evm_user_wallets.py +176 -25
- package/agent-wallet/agent_wallet/keystore.py +112 -17
- package/agent-wallet/agent_wallet/providers/x402.py +9 -1
- package/agent-wallet/agent_wallet/user_wallets.py +2 -0
- package/agent-wallet/agent_wallet/wallet_layer/solana.py +2 -0
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +1 -1
- package/bin/lib/evm-daemon.mjs +375 -0
- package/bin/openclaw-agent-wallet.mjs +7 -0
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
- package/claude-code/plugins/agent-wallet/README.md +2 -0
- package/claude-code/plugins/agent-wallet/commands/cards.md +129 -0
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/README.md +6 -2
- package/codex/plugins/agent-wallet/skills/cards/SKILL.md +119 -0
- package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
- package/package.json +2 -2
- package/wdk-btc-wallet/package.json +1 -1
- package/wdk-evm-wallet/package.json +3 -2
- package/wdk-evm-wallet/src/server.js +24 -3
- package/wdk-evm-wallet/src/shutdown.js +63 -0
|
@@ -12,8 +12,8 @@ from pathlib import Path
|
|
|
12
12
|
|
|
13
13
|
from agent_wallet.config import (
|
|
14
14
|
read_boot_key_from_keystore,
|
|
15
|
+
resolve_boot_key,
|
|
15
16
|
resolve_openclaw_home,
|
|
16
|
-
settings,
|
|
17
17
|
)
|
|
18
18
|
from agent_wallet.file_ops import atomic_write_text
|
|
19
19
|
from agent_wallet.keystore import (
|
|
@@ -26,20 +26,6 @@ from agent_wallet.keystore import (
|
|
|
26
26
|
_ENV_LINE_PREFIX = "AGENT_WALLET_BOOT_KEY="
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def _read_legacy_boot_key() -> str:
|
|
30
|
-
"""The authoritative live key from legacy sources only (env override -> file)."""
|
|
31
|
-
direct = os.getenv("AGENT_WALLET_BOOT_KEY", settings.agent_wallet_boot_key).strip()
|
|
32
|
-
if direct:
|
|
33
|
-
return direct
|
|
34
|
-
key_file = os.getenv("AGENT_WALLET_BOOT_KEY_FILE", settings.agent_wallet_boot_key_file).strip()
|
|
35
|
-
if key_file:
|
|
36
|
-
try:
|
|
37
|
-
return Path(key_file).expanduser().read_text(encoding="utf-8").strip()
|
|
38
|
-
except OSError:
|
|
39
|
-
return ""
|
|
40
|
-
return ""
|
|
41
|
-
|
|
42
|
-
|
|
43
29
|
def _env_boot_key_value(line: str) -> str | None:
|
|
44
30
|
"""Return the boot-key value on an AGENT_WALLET_BOOT_KEY= line, else None."""
|
|
45
31
|
stripped = line.strip()
|
|
@@ -140,23 +126,26 @@ def migrate_boot_key_to_keystore() -> dict:
|
|
|
140
126
|
"removed_boot_key_file": False, "reason": "no-os-keystore"}
|
|
141
127
|
|
|
142
128
|
authoritative = read_boot_key_from_keystore()
|
|
129
|
+
resolved = resolve_boot_key()
|
|
143
130
|
first_time = False
|
|
144
|
-
if
|
|
145
|
-
|
|
146
|
-
if not legacy_key:
|
|
131
|
+
if authoritative != resolved:
|
|
132
|
+
if not resolved:
|
|
147
133
|
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
148
134
|
"removed_boot_key_file": False, "reason": "no-legacy-key"}
|
|
149
135
|
try:
|
|
150
|
-
store.set(BOOT_KEY_ITEM,
|
|
136
|
+
store.set(BOOT_KEY_ITEM, resolved)
|
|
151
137
|
except Exception as exc:
|
|
152
138
|
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
153
139
|
"removed_boot_key_file": False, "reason": f"keystore-set-failed: {exc}"}
|
|
154
140
|
# Verify-before-delete.
|
|
155
|
-
if store.get(BOOT_KEY_ITEM) !=
|
|
141
|
+
if store.get(BOOT_KEY_ITEM) != resolved:
|
|
156
142
|
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
157
143
|
"removed_boot_key_file": False, "reason": "verify-failed"}
|
|
158
|
-
authoritative =
|
|
144
|
+
authoritative = resolved
|
|
159
145
|
first_time = True
|
|
146
|
+
elif not authoritative:
|
|
147
|
+
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
148
|
+
"removed_boot_key_file": False, "reason": "no-legacy-key"}
|
|
160
149
|
|
|
161
150
|
record_keystore_backend(store)
|
|
162
151
|
swept, removed = _sweep_plaintext(home, authoritative)
|
|
@@ -448,6 +448,19 @@ def read_boot_key_from_keystore() -> str:
|
|
|
448
448
|
return ""
|
|
449
449
|
|
|
450
450
|
|
|
451
|
+
def read_boot_key_from_legacy_unscoped_keystore() -> str:
|
|
452
|
+
"""Read the pre-home-scoping native service for compatibility only."""
|
|
453
|
+
sealed_path = resolve_openclaw_home() / "sealed_keys.json"
|
|
454
|
+
if not sealed_path.exists():
|
|
455
|
+
return ""
|
|
456
|
+
try:
|
|
457
|
+
from agent_wallet.keystore import read_legacy_unscoped_boot_key
|
|
458
|
+
|
|
459
|
+
return read_legacy_unscoped_boot_key()
|
|
460
|
+
except Exception:
|
|
461
|
+
return ""
|
|
462
|
+
|
|
463
|
+
|
|
451
464
|
def _read_boot_key_file(path_value: str) -> str:
|
|
452
465
|
if not path_value.strip():
|
|
453
466
|
return ""
|
|
@@ -477,6 +490,13 @@ def _boot_key_candidates() -> Iterator[tuple[str, str]]:
|
|
|
477
490
|
if keystore_item:
|
|
478
491
|
yield keystore_item
|
|
479
492
|
|
|
493
|
+
legacy_keystore_item = candidate(
|
|
494
|
+
"legacy_unscoped_keystore",
|
|
495
|
+
read_boot_key_from_legacy_unscoped_keystore(),
|
|
496
|
+
)
|
|
497
|
+
if legacy_keystore_item:
|
|
498
|
+
yield legacy_keystore_item
|
|
499
|
+
|
|
480
500
|
configured_file = os.getenv(
|
|
481
501
|
"AGENT_WALLET_BOOT_KEY_FILE", settings.agent_wallet_boot_key_file
|
|
482
502
|
).strip()
|
|
@@ -200,6 +200,121 @@ def _listening_pids(port: int) -> list[int]:
|
|
|
200
200
|
return pids
|
|
201
201
|
|
|
202
202
|
|
|
203
|
+
def _daemon_takeover_disabled() -> bool:
|
|
204
|
+
return os.getenv("OPENCLAW_EVM_DISABLE_DAEMON_TAKEOVER", "").strip().lower() in {
|
|
205
|
+
"1",
|
|
206
|
+
"true",
|
|
207
|
+
"yes",
|
|
208
|
+
"on",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _takeover_refusal(port: int, listeners: list[int], data_dir: str, reason: str) -> str:
|
|
213
|
+
pids = ", ".join(str(pid) for pid in listeners) if listeners else "unknown"
|
|
214
|
+
return (
|
|
215
|
+
f"Refusing to stop the wdk-evm-wallet on port {port}: {reason}. "
|
|
216
|
+
f"Listening PIDs: {pids}. Daemon dataDir: {data_dir or 'unknown'}. "
|
|
217
|
+
f"To clear it manually: lsof -nP -iTCP:{port} -sTCP:LISTEN, then kill <pid>."
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
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
|
+
def _process_exists(pid: int) -> bool:
|
|
247
|
+
try:
|
|
248
|
+
os.kill(pid, 0)
|
|
249
|
+
return True
|
|
250
|
+
except ProcessLookupError:
|
|
251
|
+
return False
|
|
252
|
+
except PermissionError:
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _process_released_service(pid: int, port: int, service_url: str) -> bool:
|
|
257
|
+
if not _process_exists(pid):
|
|
258
|
+
return True
|
|
259
|
+
# A daemon signalled by a process other than its parent can remain briefly
|
|
260
|
+
# as a zombie. Once its listener and health endpoint are both gone, it no
|
|
261
|
+
# longer blocks the updated runtime and is considered released.
|
|
262
|
+
return pid not in _listening_pids(port) and _service_health(service_url) is None
|
|
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
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _resolve_stoppable_pid(current_health: dict[str, Any], port: int) -> int:
|
|
289
|
+
"""Return a strictly verified same-home daemon PID, or 0 to fail closed."""
|
|
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
|
|
296
|
+
try:
|
|
297
|
+
reported_pid = int(current_health.get("pid") or 0)
|
|
298
|
+
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
|
+
return 0
|
|
315
|
+
return candidate
|
|
316
|
+
|
|
317
|
+
|
|
203
318
|
def _stop_local_service(service_url: str, health: dict[str, Any] | None = None) -> None:
|
|
204
319
|
"""Gracefully stop a local wdk-evm-wallet daemon so a fresh one can start.
|
|
205
320
|
|
|
@@ -212,37 +327,57 @@ def _stop_local_service(service_url: str, health: dict[str, Any] | None = None)
|
|
|
212
327
|
f"Refusing to stop an unidentified service on port {port}."
|
|
213
328
|
)
|
|
214
329
|
listeners = _listening_pids(port)
|
|
215
|
-
owner = _read_service_owner()
|
|
216
|
-
expected_instance = _expected_local_service_instance_id()
|
|
217
|
-
reported_instance = str(current_health.get("instanceId") or "").strip()
|
|
218
330
|
reported_data_dir = str(current_health.get("dataDir") or "").strip()
|
|
219
|
-
try:
|
|
220
|
-
reported_pid = int(current_health.get("pid") or 0)
|
|
221
|
-
except (TypeError, ValueError):
|
|
222
|
-
reported_pid = 0
|
|
223
331
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
and int(owner.get("port") or 0) == port
|
|
332
|
+
if _daemon_takeover_disabled():
|
|
333
|
+
raise WalletBackendError(
|
|
334
|
+
_takeover_refusal(
|
|
335
|
+
port,
|
|
336
|
+
listeners,
|
|
337
|
+
reported_data_dir,
|
|
338
|
+
"takeover is disabled by OPENCLAW_EVM_DISABLE_DAEMON_TAKEOVER",
|
|
232
339
|
)
|
|
233
|
-
or int(owner.get("pid") or 0) not in listeners
|
|
234
340
|
)
|
|
235
|
-
if owner_matches and (not listeners or reported_pid in listeners):
|
|
236
|
-
owned_pid = reported_pid
|
|
237
|
-
elif not reported_instance and reported_data_dir and len(listeners) == 1:
|
|
238
|
-
# One-time compatibility path for pre-instance-id daemons.
|
|
239
|
-
owned_pid = listeners[0]
|
|
240
341
|
|
|
342
|
+
if not _same_path(reported_data_dir, _expected_local_service_data_dir()):
|
|
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)
|
|
241
353
|
if owned_pid <= 0:
|
|
242
354
|
raise WalletBackendError(
|
|
243
|
-
|
|
244
|
-
|
|
355
|
+
_takeover_refusal(
|
|
356
|
+
port,
|
|
357
|
+
listeners,
|
|
358
|
+
reported_data_dir,
|
|
359
|
+
"the listening process could not be identified",
|
|
360
|
+
)
|
|
245
361
|
)
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
os.kill(owned_pid, 0)
|
|
365
|
+
except ProcessLookupError:
|
|
366
|
+
try:
|
|
367
|
+
_service_owner_path().unlink()
|
|
368
|
+
except FileNotFoundError:
|
|
369
|
+
pass
|
|
370
|
+
return
|
|
371
|
+
except PermissionError as exc:
|
|
372
|
+
raise WalletBackendError(
|
|
373
|
+
_takeover_refusal(
|
|
374
|
+
port,
|
|
375
|
+
listeners,
|
|
376
|
+
reported_data_dir,
|
|
377
|
+
f"pid {owned_pid} belongs to another user ({exc})",
|
|
378
|
+
)
|
|
379
|
+
) from exc
|
|
380
|
+
|
|
246
381
|
try:
|
|
247
382
|
os.kill(owned_pid, signal.SIGTERM)
|
|
248
383
|
except ProcessLookupError:
|
|
@@ -253,20 +388,36 @@ def _stop_local_service(service_url: str, health: dict[str, Any] | None = None)
|
|
|
253
388
|
) from exc
|
|
254
389
|
deadline = time.time() + 10.0
|
|
255
390
|
while time.time() < deadline:
|
|
256
|
-
if
|
|
391
|
+
if _process_released_service(owned_pid, port, service_url):
|
|
257
392
|
try:
|
|
258
393
|
_service_owner_path().unlink()
|
|
259
394
|
except FileNotFoundError:
|
|
260
395
|
pass
|
|
261
396
|
return
|
|
262
397
|
time.sleep(0.3)
|
|
398
|
+
# Re-run the socket/cwd/owner checks immediately before a hard stop. A PID
|
|
399
|
+
# that exited and was reused must never inherit permission from old health.
|
|
400
|
+
refreshed_health = _service_health(service_url)
|
|
401
|
+
if (
|
|
402
|
+
not refreshed_health
|
|
403
|
+
or refreshed_health.get("service") != "wdk-evm-wallet"
|
|
404
|
+
or _resolve_stoppable_pid(refreshed_health, port) != owned_pid
|
|
405
|
+
):
|
|
406
|
+
raise WalletBackendError(
|
|
407
|
+
_takeover_refusal(
|
|
408
|
+
port,
|
|
409
|
+
_listening_pids(port),
|
|
410
|
+
str((refreshed_health or {}).get("dataDir") or ""),
|
|
411
|
+
"process identity changed before the hard stop",
|
|
412
|
+
)
|
|
413
|
+
)
|
|
263
414
|
try:
|
|
264
415
|
os.kill(owned_pid, signal.SIGKILL)
|
|
265
416
|
except ProcessLookupError:
|
|
266
417
|
pass
|
|
267
418
|
deadline = time.time() + 5.0
|
|
268
419
|
while time.time() < deadline:
|
|
269
|
-
if
|
|
420
|
+
if _process_released_service(owned_pid, port, service_url):
|
|
270
421
|
try:
|
|
271
422
|
_service_owner_path().unlink()
|
|
272
423
|
except FileNotFoundError:
|
|
@@ -13,6 +13,7 @@ BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import hashlib
|
|
16
17
|
import json
|
|
17
18
|
import os
|
|
18
19
|
import platform
|
|
@@ -36,9 +37,53 @@ _SUBPROCESS_TIMEOUT = 10.0
|
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
def _service() -> str:
|
|
39
|
-
"""
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
"""Return the keystore namespace for the active wallet home.
|
|
41
|
+
|
|
42
|
+
Desktop keystores are global to the OS account, while ``OPENCLAW_HOME`` can
|
|
43
|
+
point at an isolated test or secondary installation. Keep the historical
|
|
44
|
+
service for the default home, but derive a stable namespace for every other
|
|
45
|
+
home so one installation cannot replace another installation's boot key.
|
|
46
|
+
Tests can still choose a readable throwaway namespace explicitly.
|
|
47
|
+
"""
|
|
48
|
+
explicit = os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip()
|
|
49
|
+
if explicit:
|
|
50
|
+
return explicit
|
|
51
|
+
home = resolve_openclaw_home().expanduser().resolve()
|
|
52
|
+
default_home = (_account_home() / ".openclaw").resolve()
|
|
53
|
+
if home == default_home:
|
|
54
|
+
return KEYSTORE_SERVICE
|
|
55
|
+
home_hash = hashlib.sha256(str(home).encode("utf-8")).hexdigest()[:16]
|
|
56
|
+
return f"{KEYSTORE_SERVICE}.home-{home_hash}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _account_home() -> Path:
|
|
60
|
+
"""Return the OS account home without trusting a test-overridden ``HOME``."""
|
|
61
|
+
if os.name == "nt":
|
|
62
|
+
user_profile = os.getenv("USERPROFILE", "").strip()
|
|
63
|
+
if user_profile:
|
|
64
|
+
return Path(user_profile)
|
|
65
|
+
drive = os.getenv("HOMEDRIVE", "").strip()
|
|
66
|
+
home_path = os.getenv("HOMEPATH", "").strip()
|
|
67
|
+
if drive and home_path:
|
|
68
|
+
return Path(f"{drive}{home_path}")
|
|
69
|
+
try:
|
|
70
|
+
import pwd
|
|
71
|
+
|
|
72
|
+
return Path(pwd.getpwuid(os.getuid()).pw_dir)
|
|
73
|
+
except (ImportError, KeyError, OSError):
|
|
74
|
+
return Path.home()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _legacy_unscoped_service() -> str | None:
|
|
78
|
+
"""Return the pre-home-scoping service for safe compatibility reads."""
|
|
79
|
+
if os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip():
|
|
80
|
+
return None
|
|
81
|
+
if _service() == KEYSTORE_SERVICE:
|
|
82
|
+
return None
|
|
83
|
+
state = _read_backend_state_payload()
|
|
84
|
+
if state and state["service"] == KEYSTORE_SERVICE:
|
|
85
|
+
return KEYSTORE_SERVICE
|
|
86
|
+
return None
|
|
42
87
|
|
|
43
88
|
|
|
44
89
|
def _backend_preference() -> str:
|
|
@@ -56,7 +101,7 @@ def _state_path() -> Path:
|
|
|
56
101
|
return resolve_openclaw_home() / "keystore" / _KEYSTORE_STATE_FILENAME
|
|
57
102
|
|
|
58
103
|
|
|
59
|
-
def
|
|
104
|
+
def _read_backend_state_payload() -> dict[str, str] | None:
|
|
60
105
|
try:
|
|
61
106
|
payload = json.loads(_state_path().read_text(encoding="utf-8"))
|
|
62
107
|
except (OSError, ValueError):
|
|
@@ -72,9 +117,17 @@ def _read_backend_state() -> dict[str, str] | None:
|
|
|
72
117
|
"plaintext-file",
|
|
73
118
|
}:
|
|
74
119
|
return None
|
|
120
|
+
return {"backend": backend, "service": service}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _read_backend_state() -> dict[str, str] | None:
|
|
124
|
+
payload = _read_backend_state_payload()
|
|
125
|
+
if payload is None:
|
|
126
|
+
return None
|
|
127
|
+
service = payload["service"]
|
|
75
128
|
if service != _service():
|
|
76
129
|
return None
|
|
77
|
-
return
|
|
130
|
+
return payload
|
|
78
131
|
|
|
79
132
|
|
|
80
133
|
class KeyStoreError(Exception):
|
|
@@ -129,11 +182,14 @@ def _run(
|
|
|
129
182
|
class MacKeychainStore:
|
|
130
183
|
backend_id = "macos-keychain"
|
|
131
184
|
|
|
185
|
+
def __init__(self, service: str | None = None) -> None:
|
|
186
|
+
self.service = service or _service()
|
|
187
|
+
|
|
132
188
|
def available(self) -> bool:
|
|
133
189
|
return platform.system() == "Darwin" and Path(_SECURITY_BIN).exists()
|
|
134
190
|
|
|
135
191
|
def get(self, name: str) -> str | None:
|
|
136
|
-
proc = _run([_SECURITY_BIN, "find-generic-password", "-s",
|
|
192
|
+
proc = _run([_SECURITY_BIN, "find-generic-password", "-s", self.service, "-a", name, "-w"])
|
|
137
193
|
if proc.returncode != 0:
|
|
138
194
|
return None # item not found (44) or other non-fatal lookup miss
|
|
139
195
|
value = proc.stdout.rstrip("\n")
|
|
@@ -150,17 +206,17 @@ class MacKeychainStore:
|
|
|
150
206
|
# Trade-off: any process running as this user can read the key without a
|
|
151
207
|
# prompt — the same runtime exposure as a 0600 file. At-rest protection
|
|
152
208
|
# (backups, synced home dirs, a stolen disk) is fully preserved.
|
|
153
|
-
_run([_SECURITY_BIN, "delete-generic-password", "-s",
|
|
209
|
+
_run([_SECURITY_BIN, "delete-generic-password", "-s", self.service, "-a", name])
|
|
154
210
|
proc = _run([
|
|
155
211
|
_SECURITY_BIN, "add-generic-password",
|
|
156
|
-
"-s",
|
|
212
|
+
"-s", self.service, "-a", name,
|
|
157
213
|
"-w", value, "-A",
|
|
158
214
|
])
|
|
159
215
|
if proc.returncode != 0:
|
|
160
216
|
raise KeyStoreError(f"security add-generic-password failed: {proc.stderr.strip()}")
|
|
161
217
|
|
|
162
218
|
def delete(self, name: str) -> None:
|
|
163
|
-
_run([_SECURITY_BIN, "delete-generic-password", "-s",
|
|
219
|
+
_run([_SECURITY_BIN, "delete-generic-password", "-s", self.service, "-a", name])
|
|
164
220
|
|
|
165
221
|
|
|
166
222
|
class WindowsDpapiStore:
|
|
@@ -215,19 +271,22 @@ class WindowsDpapiStore:
|
|
|
215
271
|
class LinuxSecretServiceStore:
|
|
216
272
|
backend_id = "linux-secretservice"
|
|
217
273
|
|
|
274
|
+
def __init__(self, service: str | None = None) -> None:
|
|
275
|
+
self.service = service or _service()
|
|
276
|
+
|
|
218
277
|
def available(self) -> bool:
|
|
219
278
|
if platform.system() != "Linux" or shutil.which("secret-tool") is None:
|
|
220
279
|
return False
|
|
221
280
|
# A probe lookup succeeds (rc 0/1) only when a Secret Service answers;
|
|
222
281
|
# a missing/unreachable service errors out (rc >1) or times out.
|
|
223
282
|
try:
|
|
224
|
-
proc = _run(["secret-tool", "lookup", "service",
|
|
283
|
+
proc = _run(["secret-tool", "lookup", "service", self.service, "account", "__probe__"])
|
|
225
284
|
except subprocess.TimeoutExpired:
|
|
226
285
|
return False
|
|
227
286
|
return proc.returncode in (0, 1)
|
|
228
287
|
|
|
229
288
|
def get(self, name: str) -> str | None:
|
|
230
|
-
proc = _run(["secret-tool", "lookup", "service",
|
|
289
|
+
proc = _run(["secret-tool", "lookup", "service", self.service, "account", name])
|
|
231
290
|
if proc.returncode != 0:
|
|
232
291
|
return None
|
|
233
292
|
value = proc.stdout.rstrip("\n")
|
|
@@ -235,15 +294,15 @@ class LinuxSecretServiceStore:
|
|
|
235
294
|
|
|
236
295
|
def set(self, name: str, value: str) -> None:
|
|
237
296
|
proc = _run(
|
|
238
|
-
["secret-tool", "store", "--label", f"{
|
|
239
|
-
"service",
|
|
297
|
+
["secret-tool", "store", "--label", f"{self.service} {name}",
|
|
298
|
+
"service", self.service, "account", name],
|
|
240
299
|
input_text=value,
|
|
241
300
|
)
|
|
242
301
|
if proc.returncode != 0:
|
|
243
302
|
raise KeyStoreError(f"secret-tool store failed: {proc.stderr.strip()}")
|
|
244
303
|
|
|
245
304
|
def delete(self, name: str) -> None:
|
|
246
|
-
_run(["secret-tool", "clear", "service",
|
|
305
|
+
_run(["secret-tool", "clear", "service", self.service, "account", name])
|
|
247
306
|
|
|
248
307
|
|
|
249
308
|
class PlaintextFileStore:
|
|
@@ -340,16 +399,52 @@ def _resolve_keystore_uncached() -> KeyStore:
|
|
|
340
399
|
return PlaintextFileStore()
|
|
341
400
|
|
|
342
401
|
|
|
343
|
-
def _store_for_backend(backend_id: str) -> KeyStore | None:
|
|
402
|
+
def _store_for_backend(backend_id: str, *, service: str | None = None) -> KeyStore | None:
|
|
344
403
|
stores: dict[str, KeyStore] = {
|
|
345
|
-
"macos-keychain": MacKeychainStore(),
|
|
404
|
+
"macos-keychain": MacKeychainStore(service),
|
|
346
405
|
"windows-dpapi": WindowsDpapiStore(),
|
|
347
|
-
"linux-secretservice": LinuxSecretServiceStore(),
|
|
406
|
+
"linux-secretservice": LinuxSecretServiceStore(service),
|
|
348
407
|
"plaintext-file": PlaintextFileStore(),
|
|
349
408
|
}
|
|
350
409
|
return stores.get(backend_id)
|
|
351
410
|
|
|
352
411
|
|
|
412
|
+
def read_legacy_unscoped_boot_key() -> str:
|
|
413
|
+
"""Read the historical global service without probing or writing it.
|
|
414
|
+
|
|
415
|
+
This is used only as a compatibility candidate for an existing sealed
|
|
416
|
+
custom-home installation. The caller must verify the value against that
|
|
417
|
+
home's ``sealed_keys.json`` before accepting or migrating it.
|
|
418
|
+
"""
|
|
419
|
+
legacy_service = _legacy_unscoped_service()
|
|
420
|
+
preference = _backend_preference()
|
|
421
|
+
if not legacy_service or preference in {"plain", "plaintext", "plaintext-file", "file"}:
|
|
422
|
+
return ""
|
|
423
|
+
|
|
424
|
+
if preference in {"macos", "macos-keychain", "keychain"}:
|
|
425
|
+
candidates: list[KeyStore] = [MacKeychainStore(legacy_service)]
|
|
426
|
+
elif preference in {"linux", "linux-secretservice", "secretservice"}:
|
|
427
|
+
candidates = [LinuxSecretServiceStore(legacy_service)]
|
|
428
|
+
elif preference in {"windows", "windows-dpapi", "dpapi"}:
|
|
429
|
+
candidates = []
|
|
430
|
+
else:
|
|
431
|
+
candidates = [
|
|
432
|
+
MacKeychainStore(legacy_service),
|
|
433
|
+
LinuxSecretServiceStore(legacy_service),
|
|
434
|
+
]
|
|
435
|
+
|
|
436
|
+
for candidate in candidates:
|
|
437
|
+
try:
|
|
438
|
+
if not candidate.available():
|
|
439
|
+
continue
|
|
440
|
+
value = candidate.get(BOOT_KEY_ITEM)
|
|
441
|
+
if isinstance(value, str) and value.strip():
|
|
442
|
+
return value.strip()
|
|
443
|
+
except Exception:
|
|
444
|
+
continue
|
|
445
|
+
return ""
|
|
446
|
+
|
|
447
|
+
|
|
353
448
|
def record_keystore_backend(store: KeyStore) -> dict[str, object]:
|
|
354
449
|
"""Persist a verified boot-key backend without replacing a temporary fallback."""
|
|
355
450
|
existing = _read_backend_state()
|
|
@@ -504,7 +504,15 @@ def _requirement_compatibility(requirement: dict[str, Any], backend: AgentWallet
|
|
|
504
504
|
elif chain == "evm" and scheme == "upto" and not _evm_payment_requirement_supported(requirement):
|
|
505
505
|
reason = "This EVM upto payment is missing a facilitatorAddress in its extra data, so it cannot be signed."
|
|
506
506
|
elif planned_execution_supported and wallet_network_matches:
|
|
507
|
-
|
|
507
|
+
if chain == "solana" and getattr(backend, "read_only", False):
|
|
508
|
+
reason = (
|
|
509
|
+
"Signer not loaded in this read-only preview context by "
|
|
510
|
+
"design (avoids unnecessary key material access on cheap "
|
|
511
|
+
"preview calls) -- the real x402_pay_request call loads "
|
|
512
|
+
"the signer and can still succeed here."
|
|
513
|
+
)
|
|
514
|
+
else:
|
|
515
|
+
reason = "Wallet network matches, but this backend does not yet expose a supported x402 signer path."
|
|
508
516
|
elif planned_execution_supported:
|
|
509
517
|
reason = "Planned execution path exists, but the requirement targets a different network than the active wallet."
|
|
510
518
|
else:
|
|
@@ -528,6 +528,7 @@ def create_openclaw_solana_backend(
|
|
|
528
528
|
signer=signer,
|
|
529
529
|
address=resolved_address or None,
|
|
530
530
|
sign_only=effective_sign_only,
|
|
531
|
+
read_only=read_only,
|
|
531
532
|
rpc_provider_mode=str(rpc_config["mode"]),
|
|
532
533
|
rpc_provider=str(rpc_config["provider"]),
|
|
533
534
|
rpc_transport=str(rpc_config["transport"]),
|
|
@@ -554,6 +555,7 @@ def create_openclaw_solana_backend(
|
|
|
554
555
|
signer=None,
|
|
555
556
|
address=wallet_info["address"] or None,
|
|
556
557
|
sign_only=effective_sign_only,
|
|
558
|
+
read_only=read_only,
|
|
557
559
|
rpc_provider_mode=str(rpc_config["mode"]),
|
|
558
560
|
rpc_provider=str(rpc_config["provider"]),
|
|
559
561
|
rpc_transport=str(rpc_config["transport"]),
|
|
@@ -261,6 +261,7 @@ class SolanaWalletBackend(AgentWalletBackend):
|
|
|
261
261
|
signer: SolanaLocalKeypairSigner | None = None,
|
|
262
262
|
address: str | None = None,
|
|
263
263
|
sign_only: bool = True,
|
|
264
|
+
read_only: bool = False,
|
|
264
265
|
rpc_provider_mode: str | None = None,
|
|
265
266
|
rpc_provider: str | None = None,
|
|
266
267
|
rpc_transport: str | None = None,
|
|
@@ -281,6 +282,7 @@ class SolanaWalletBackend(AgentWalletBackend):
|
|
|
281
282
|
self.commitment = commitment
|
|
282
283
|
self.network = normalize_solana_network(network)
|
|
283
284
|
self.signer = signer
|
|
285
|
+
self.read_only = read_only
|
|
284
286
|
self.address = final_address
|
|
285
287
|
self.sign_only = sign_only
|
|
286
288
|
self.rpc_provider_mode = rpc_provider_mode
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "agent-wallet",
|
|
3
3
|
"name": "Agent Wallet",
|
|
4
4
|
"description": "Plugin-friendly wallet backend for OpenClaw agents with safe wallet tools and runtime instructions across Solana, local BTC, and local EVM.",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.93",
|
|
6
6
|
"skills": ["skills/wallet-operator"],
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|