@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.
Files changed (29) hide show
  1. package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +1 -1
  2. package/.openclaw/extensions/agent-wallet/package.json +1 -1
  3. package/CHANGELOG.md +38 -0
  4. package/README.md +86 -490
  5. package/VERSION +1 -1
  6. package/agent-wallet/agent_wallet/__init__.py +1 -1
  7. package/agent-wallet/agent_wallet/boot_key_migration.py +10 -21
  8. package/agent-wallet/agent_wallet/config.py +20 -0
  9. package/agent-wallet/agent_wallet/evm_user_wallets.py +176 -25
  10. package/agent-wallet/agent_wallet/keystore.py +112 -17
  11. package/agent-wallet/agent_wallet/providers/x402.py +9 -1
  12. package/agent-wallet/agent_wallet/user_wallets.py +2 -0
  13. package/agent-wallet/agent_wallet/wallet_layer/solana.py +2 -0
  14. package/agent-wallet/openclaw.plugin.json +1 -1
  15. package/agent-wallet/pyproject.toml +1 -1
  16. package/bin/lib/evm-daemon.mjs +375 -0
  17. package/bin/openclaw-agent-wallet.mjs +7 -0
  18. package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
  19. package/claude-code/plugins/agent-wallet/README.md +2 -0
  20. package/claude-code/plugins/agent-wallet/commands/cards.md +129 -0
  21. package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
  22. package/codex/plugins/agent-wallet/README.md +6 -2
  23. package/codex/plugins/agent-wallet/skills/cards/SKILL.md +119 -0
  24. package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
  25. package/package.json +2 -2
  26. package/wdk-btc-wallet/package.json +1 -1
  27. package/wdk-evm-wallet/package.json +3 -2
  28. package/wdk-evm-wallet/src/server.js +24 -3
  29. 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 not authoritative:
145
- legacy_key = _read_legacy_boot_key()
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, legacy_key)
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) != legacy_key:
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 = legacy_key
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
- owned_pid = 0
225
- if reported_instance == expected_instance and reported_pid > 0:
226
- owner_matches = (
227
- not owner
228
- or (
229
- int(owner.get("pid") or 0) == reported_pid
230
- and str(owner.get("instance_id") or "") == expected_instance
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
- f"A stale wdk-evm-wallet is running on port {port} but ownership could not be verified. "
244
- "Stop it manually and retry."
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 _service_health(service_url) is None:
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 _service_health(service_url) is None:
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
- """Keychain/Secret-Service service name. Overridable so tests never touch the
40
- real shared slot (the OS keychain is global, not scoped to OPENCLAW_HOME)."""
41
- return os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip() or KEYSTORE_SERVICE
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 _read_backend_state() -> dict[str, str] | None:
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 {"backend": backend, "service": service}
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", _service(), "-a", name, "-w"])
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", _service(), "-a", name])
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", _service(), "-a", name,
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", _service(), "-a", name])
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", _service(), "account", "__probe__"])
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", _service(), "account", name])
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"{_service()} {name}",
239
- "service", _service(), "account", name],
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", _service(), "account", name])
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
- reason = "Wallet network matches, but this backend does not yet expose a supported x402 signer path."
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.91",
5
+ "version": "0.1.93",
6
6
  "skills": ["skills/wallet-operator"],
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openclaw-agent-wallet"
7
- version = "0.1.91"
7
+ version = "0.1.93"
8
8
  description = "Plugin-friendly wallet backend for OpenClaw agents"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [