@agentlayer.tech/wallet 0.1.64 → 0.1.65

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.
@@ -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.64",
5
+ "version": "0.1.65",
6
6
  "contracts": {
7
7
  "tools": [
8
8
  "agentlayer_autonomous_approve",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayertech/agent-wallet-plugin",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "description": "OpenClaw plugin bridge for the AgentLayer wallet runtime.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN ../../../LICENSE",
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.64
1
+ 0.1.65
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Keep in sync with package.json, pyproject.toml, and the npm installer version.
4
4
  # scripts/check_release_version.mjs enforces this on release.
5
- __version__ = "0.1.64"
5
+ __version__ = "0.1.65"
6
6
 
7
7
  __all__ = [
8
8
  "config",
@@ -80,6 +80,7 @@ def reload_settings() -> Settings:
80
80
  refreshed = Settings()
81
81
  for field_name in Settings.model_fields:
82
82
  setattr(settings, field_name, getattr(refreshed, field_name))
83
+ clear_secret_caches()
83
84
  return settings
84
85
 
85
86
 
@@ -385,13 +386,54 @@ def _env_bool(name: str, default: bool) -> bool:
385
386
  return raw.strip().lower() in {"1", "true", "yes", "on"}
386
387
 
387
388
 
389
+ def envelope_kdf_migration_enabled() -> bool:
390
+ """Whether argon2id envelopes are lazily rewritten to hkdf-sha256 after a
391
+ successful decrypt. Disable with AGENT_WALLET_ENVELOPE_KDF_MIGRATION=0
392
+ (e.g. when a rollback to a pre-hkdf runtime must stay possible)."""
393
+ return _env_bool("AGENT_WALLET_ENVELOPE_KDF_MIGRATION", True)
394
+
395
+
396
+ _boot_key_keystore_cache: dict[str, str] = {}
397
+
398
+
399
+ def clear_secret_caches() -> None:
400
+ """Reset all process-local secret-resolution caches.
401
+
402
+ Covers the memoized keystore backend, the boot key read from the OS
403
+ keystore, unsealed secrets, and derived envelope keys. Wired into
404
+ ``reload_settings`` so config reloads always observe fresh state; call it
405
+ directly in tests that rotate keystore contents in-process.
406
+ """
407
+ _boot_key_keystore_cache.clear()
408
+ from agent_wallet.keystore import clear_keystore_cache
409
+
410
+ clear_keystore_cache()
411
+ from agent_wallet.sealed_keys import clear_unseal_cache
412
+
413
+ clear_unseal_cache()
414
+ from agent_wallet.encrypted_storage import clear_derived_key_cache
415
+
416
+ clear_derived_key_cache()
417
+
418
+
388
419
  def read_boot_key_from_keystore() -> str:
389
- """Read the boot key from the OS keystore. Never raises; '' on any failure."""
420
+ """Read the boot key from the OS keystore. Never raises; '' on any failure.
421
+
422
+ Successful (non-empty) reads are memoized per keystore service for the
423
+ process lifetime — every uncached read costs a subprocess call.
424
+ """
390
425
  try:
391
426
  from agent_wallet.keystore import BOOT_KEY_ITEM, resolve_keystore
392
427
 
428
+ service_key = os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip()
429
+ cached = _boot_key_keystore_cache.get(service_key)
430
+ if cached:
431
+ return cached
393
432
  value = resolve_keystore().get(BOOT_KEY_ITEM)
394
- return value.strip() if isinstance(value, str) else ""
433
+ text = value.strip() if isinstance(value, str) else ""
434
+ if text:
435
+ _boot_key_keystore_cache[service_key] = text
436
+ return text
395
437
  except Exception:
396
438
  return ""
397
439
 
@@ -6,6 +6,7 @@ import base64
6
6
  import hashlib
7
7
  import hmac
8
8
  import json
9
+ import os
9
10
  from pathlib import Path
10
11
  from typing import Any
11
12
 
@@ -17,6 +18,10 @@ ENCRYPTED_WALLET_KIND = "openclaw-agent-wallet-secret"
17
18
  ENCRYPTED_WALLET_VERSION = 1
18
19
  USER_SCOPED_KEY_SALT = b"openclaw-agent-wallet-user-key-v1"
19
20
 
21
+ KDF_ARGON2ID = "argon2id"
22
+ KDF_HKDF_SHA256 = "hkdf-sha256"
23
+ _HKDF_INFO = b"openclaw-agent-wallet-envelope-hkdf-v1"
24
+
20
25
 
21
26
  def _load_secretbox():
22
27
  try:
@@ -28,11 +33,19 @@ def _load_secretbox():
28
33
  return pwhash, secret, utils
29
34
 
30
35
 
31
- def _derive_key(master_key: str, salt: bytes) -> bytes:
32
- if not master_key.strip():
33
- raise WalletBackendError(
34
- "Encrypted wallet storage requires AGENT_WALLET_BOOT_KEY and a sealed master_key."
35
- )
36
+ # Derived envelope keys are expensive (argon2id ~1s each), so cache them by
37
+ # (kdf, master_key, salt). In-memory only, never serialized; bounded so a
38
+ # pathological caller cannot grow it without limit.
39
+ _derived_key_cache: dict[tuple[str, str, bytes], bytes] = {}
40
+ _DERIVED_KEY_CACHE_MAX = 64
41
+
42
+
43
+ def clear_derived_key_cache() -> None:
44
+ """Drop cached derived keys (wired into config.clear_secret_caches)."""
45
+ _derived_key_cache.clear()
46
+
47
+
48
+ def _kdf_argon2id(master_key: str, salt: bytes) -> bytes:
36
49
  pwhash, secret, _ = _load_secretbox()
37
50
  return pwhash.argon2id.kdf(
38
51
  secret.SecretBox.KEY_SIZE,
@@ -43,6 +56,52 @@ def _derive_key(master_key: str, salt: bytes) -> bytes:
43
56
  )
44
57
 
45
58
 
59
+ def _kdf_hkdf_sha256(master_key: str, salt: bytes) -> bytes:
60
+ # RFC 5869 extract+expand, one 32-byte block. Only safe because every
61
+ # caller passes machine-generated high-entropy key material (boot key /
62
+ # sealed master key) — password-hardness is what argon2id was paying for,
63
+ # and user passwords never reach this module.
64
+ prk = hmac.new(salt, master_key.encode("utf-8"), hashlib.sha256).digest()
65
+ return hmac.new(prk, _HKDF_INFO + b"\x01", hashlib.sha256).digest()
66
+
67
+
68
+ def _default_write_kdf() -> str:
69
+ raw = os.getenv("AGENT_WALLET_ENVELOPE_KDF", "").strip().lower()
70
+ return KDF_ARGON2ID if raw == KDF_ARGON2ID else KDF_HKDF_SHA256
71
+
72
+
73
+ def envelope_kdf(raw_text: str) -> str:
74
+ """Return the kdf recorded in an encrypted envelope ('' when not one)."""
75
+ try:
76
+ payload = json.loads(raw_text)
77
+ except json.JSONDecodeError:
78
+ return ""
79
+ if not isinstance(payload, dict) or payload.get("kind") != ENCRYPTED_WALLET_KIND:
80
+ return ""
81
+ return str(payload.get("kdf") or KDF_ARGON2ID)
82
+
83
+
84
+ def _derive_key(master_key: str, salt: bytes, *, kdf: str = KDF_ARGON2ID) -> bytes:
85
+ if not master_key.strip():
86
+ raise WalletBackendError(
87
+ "Encrypted wallet storage requires AGENT_WALLET_BOOT_KEY and a sealed master_key."
88
+ )
89
+ if kdf == KDF_HKDF_SHA256:
90
+ # Cheap enough to skip the cache entirely.
91
+ return _kdf_hkdf_sha256(master_key, bytes(salt))
92
+ if kdf != KDF_ARGON2ID:
93
+ raise WalletBackendError(f"Encrypted wallet file uses an unsupported kdf: {kdf}")
94
+ cache_key = (kdf, master_key, bytes(salt))
95
+ cached = _derived_key_cache.get(cache_key)
96
+ if cached is not None:
97
+ return cached
98
+ key = _kdf_argon2id(master_key, salt)
99
+ if len(_derived_key_cache) >= _DERIVED_KEY_CACHE_MAX:
100
+ _derived_key_cache.clear()
101
+ _derived_key_cache[cache_key] = key
102
+ return key
103
+
104
+
46
105
  def _derive_user_scoped_key(
47
106
  master_key: str,
48
107
  *,
@@ -79,19 +138,27 @@ def encrypt_secret_material(
79
138
  *,
80
139
  master_key: str | None = None,
81
140
  metadata: dict[str, Any] | None = None,
141
+ kdf: str | None = None,
82
142
  ) -> str:
83
- """Encrypt wallet secret material into a JSON envelope."""
143
+ """Encrypt wallet secret material into a JSON envelope.
144
+
145
+ ``kdf`` selects the key derivation for this envelope; None resolves the
146
+ process default (hkdf-sha256 unless AGENT_WALLET_ENVELOPE_KDF=argon2id).
147
+ """
148
+ effective_kdf = kdf if kdf is not None else _default_write_kdf()
149
+ if effective_kdf not in {KDF_ARGON2ID, KDF_HKDF_SHA256}:
150
+ raise WalletBackendError(f"Unsupported envelope kdf: {effective_kdf}")
84
151
  _, secret, utils = _load_secretbox()
85
152
  effective_master_key = master_key if master_key is not None else resolve_wallet_master_key()
86
153
  salt = utils.random(16)
87
- key = _derive_key(effective_master_key, salt)
154
+ key = _derive_key(effective_master_key, salt, kdf=effective_kdf)
88
155
  box = secret.SecretBox(key)
89
156
  encrypted = box.encrypt(secret_material.encode("utf-8"))
90
157
  payload: dict[str, Any] = {
91
158
  "kind": ENCRYPTED_WALLET_KIND,
92
159
  "version": ENCRYPTED_WALLET_VERSION,
93
160
  "cipher": "secretbox",
94
- "kdf": "argon2id",
161
+ "kdf": effective_kdf,
95
162
  "salt_b64": base64.b64encode(salt).decode("ascii"),
96
163
  "nonce_b64": base64.b64encode(encrypted.nonce).decode("ascii"),
97
164
  "ciphertext_b64": base64.b64encode(encrypted.ciphertext).decode("ascii"),
@@ -124,7 +191,8 @@ def decrypt_secret_material(
124
191
  raise WalletBackendError("Encrypted wallet file is malformed.") from exc
125
192
 
126
193
  effective_master_key = master_key if master_key is not None else resolve_wallet_master_key()
127
- key = _derive_key(effective_master_key, salt)
194
+ kdf = str(payload.get("kdf") or KDF_ARGON2ID)
195
+ key = _derive_key(effective_master_key, salt, kdf=kdf)
128
196
  box = secret.SecretBox(key)
129
197
  try:
130
198
  plaintext = box.decrypt(ciphertext, nonce)
@@ -247,7 +247,32 @@ class PlaintextFileStore:
247
247
  pass
248
248
 
249
249
 
250
+ _keystore_cache: dict[tuple[str, str], KeyStore] = {}
251
+
252
+
253
+ def clear_keystore_cache() -> None:
254
+ """Drop the memoized keystore backend (e.g. after env/service changes)."""
255
+ _keystore_cache.clear()
256
+
257
+
250
258
  def resolve_keystore() -> KeyStore:
259
+ """Memoized keystore resolution.
260
+
261
+ Resolving probes the OS keystore with subprocess calls (`security`,
262
+ `secret-tool`, powershell), so cache the winning backend per
263
+ (preference, service) for the process lifetime. Cleared by
264
+ ``agent_wallet.config.clear_secret_caches`` / ``reload_settings``.
265
+ """
266
+ cache_key = (_backend_preference(), _service())
267
+ cached = _keystore_cache.get(cache_key)
268
+ if cached is not None:
269
+ return cached
270
+ store = _resolve_keystore_uncached()
271
+ _keystore_cache[cache_key] = store
272
+ return store
273
+
274
+
275
+ def _resolve_keystore_uncached() -> KeyStore:
251
276
  """Return the first available OS-native backend, else the plaintext fallback."""
252
277
  preference = _backend_preference()
253
278
  if preference in {"plain", "plaintext", "plaintext-file", "file"}:
@@ -33,6 +33,8 @@ def _parse_csv(value: Any) -> str:
33
33
 
34
34
 
35
35
  SECRET_CONFIG_KEYS = {"privateKey", "masterKey", "approvalSecret"}
36
+ # Always-allowed floor kept for compatibility; the live gate is the dynamic
37
+ # set of adapter-declared read-only tools (_read_only_tool_names).
36
38
  READ_ONLY_WORKER_TOOLS = frozenset(
37
39
  {
38
40
  "get_wallet_balance",
@@ -41,6 +43,20 @@ READ_ONLY_WORKER_TOOLS = frozenset(
41
43
  )
42
44
 
43
45
 
46
+ def _read_only_tool_names(context: Any) -> frozenset[str]:
47
+ """All tools the resident read worker may serve: the adapter's read-only
48
+ specs for this backend, plus the legacy floor."""
49
+ names = set(READ_ONLY_WORKER_TOOLS)
50
+ try:
51
+ for tool in context.adapter.list_tools():
52
+ spec = tool.model_dump() if hasattr(tool, "model_dump") else dict(tool)
53
+ if spec.get("read_only") is True and str(spec.get("name") or "").strip():
54
+ names.add(str(spec["name"]))
55
+ except Exception:
56
+ pass
57
+ return frozenset(names)
58
+
59
+
44
60
  def _reject_secret_config_json(config: dict[str, Any]) -> None:
45
61
  present = sorted(key for key in SECRET_CONFIG_KEYS if str(config.get(key) or "").strip())
46
62
  if present:
@@ -180,8 +196,27 @@ async def _run_invoke(
180
196
  tool_name: str,
181
197
  arguments: dict[str, Any],
182
198
  config: dict[str, Any],
199
+ *,
200
+ approval_summary: dict[str, Any] | None = None,
201
+ approval_mainnet_confirmed: bool = False,
202
+ approval_ttl_seconds: int | None = None,
183
203
  ) -> dict[str, Any]:
184
204
  context = _build_runtime_context(user_id, config)
205
+ # Mint the approval token in-process when the host passed the confirmation
206
+ # summary along with the execute call: this replaces a second cold
207
+ # `issue-approval` subprocess (full interpreter boot + onboarding) with a
208
+ # local HMAC signature. A caller-provided token always wins.
209
+ if (
210
+ isinstance(approval_summary, dict)
211
+ and not str(arguments.get("approval_token") or "").strip()
212
+ ):
213
+ arguments = dict(arguments)
214
+ arguments["approval_token"] = context.issue_execute_approval(
215
+ tool_name=tool_name,
216
+ confirmation_summary=approval_summary,
217
+ mainnet_confirmed=approval_mainnet_confirmed,
218
+ ttl_seconds=approval_ttl_seconds,
219
+ )
185
220
  result = await context.adapter.invoke(tool_name, arguments)
186
221
  return result.model_dump()
187
222
 
@@ -253,11 +288,16 @@ def _error_payload_for_exception(exc: Exception) -> dict[str, Any]:
253
288
  return payload
254
289
 
255
290
 
256
- async def _run_read_worker_tool(context: Any, request: dict[str, Any]) -> dict[str, Any]:
291
+ async def _run_read_worker_tool(
292
+ context: Any,
293
+ request: dict[str, Any],
294
+ allowed_tools: frozenset[str] | None = None,
295
+ ) -> dict[str, Any]:
257
296
  tool_name = str(request.get("tool") or "").strip()
258
- if tool_name not in READ_ONLY_WORKER_TOOLS:
297
+ effective_allowed = allowed_tools if allowed_tools is not None else READ_ONLY_WORKER_TOOLS
298
+ if tool_name not in effective_allowed:
259
299
  raise WalletBackendError(
260
- f"read-worker only allows read-only tools: {', '.join(sorted(READ_ONLY_WORKER_TOOLS))}."
300
+ f"read-worker only allows read-only tools for this backend; '{tool_name}' is not one."
261
301
  )
262
302
  arguments = request.get("arguments") or {}
263
303
  if not isinstance(arguments, dict):
@@ -268,6 +308,7 @@ async def _run_read_worker_tool(context: Any, request: dict[str, Any]) -> dict[s
268
308
 
269
309
  async def _serve_read_worker(user_id: str, config: dict[str, Any]) -> int:
270
310
  context = _build_runtime_context(user_id, config, read_only=True)
311
+ allowed_tools = _read_only_tool_names(context)
271
312
  while True:
272
313
  line = sys.stdin.readline()
273
314
  if not line:
@@ -282,7 +323,7 @@ async def _serve_read_worker(user_id: str, config: dict[str, Any]) -> int:
282
323
  if request.get("op") == "shutdown":
283
324
  print(json.dumps({"id": request_id, "ok": True, "stopped": True}), flush=True)
284
325
  break
285
- payload = await _run_read_worker_tool(context, request)
326
+ payload = await _run_read_worker_tool(context, request, allowed_tools)
286
327
  print(
287
328
  json.dumps(
288
329
  {
@@ -510,6 +551,15 @@ def main() -> int:
510
551
  invoke_parser.add_argument("--tool", required=True)
511
552
  invoke_parser.add_argument("--arguments-json", default="{}")
512
553
  invoke_parser.add_argument("--config-json", default="{}")
554
+ invoke_parser.add_argument(
555
+ "--approval-summary-json",
556
+ help=(
557
+ "Confirmation summary for execute calls; when provided, the approval "
558
+ "token is minted in this process instead of a separate issue-approval run."
559
+ ),
560
+ )
561
+ invoke_parser.add_argument("--approval-mainnet-confirmed", action="store_true")
562
+ invoke_parser.add_argument("--approval-ttl-seconds", type=int)
513
563
 
514
564
  approval_parser = subparsers.add_parser("issue-approval")
515
565
  approval_parser.add_argument("--user-id", required=True)
@@ -726,12 +776,21 @@ def main() -> int:
726
776
 
727
777
  payload = import_boot_key(_read_stdin_secret("boot key"))
728
778
  else:
779
+ approval_summary = None
780
+ raw_summary = getattr(args, "approval_summary_json", None)
781
+ if raw_summary:
782
+ approval_summary = _load_json(raw_summary)
729
783
  payload = asyncio.run(
730
784
  _run_invoke(
731
785
  args.user_id,
732
786
  args.tool,
733
787
  _load_json(args.arguments_json),
734
788
  config,
789
+ approval_summary=approval_summary,
790
+ approval_mainnet_confirmed=bool(
791
+ getattr(args, "approval_mainnet_confirmed", False)
792
+ ),
793
+ approval_ttl_seconds=getattr(args, "approval_ttl_seconds", None),
735
794
  )
736
795
  )
737
796
  except Exception as exc:
@@ -11,6 +11,16 @@ from agent_wallet.wallet_layer.base import WalletBackendError
11
11
 
12
12
  SEALED_KEYS_FILENAME = "sealed_keys.json"
13
13
 
14
+ # Single-entry cache: (boot_key, path, mtime_ns, size) -> secrets. Unsealing
15
+ # runs a KDF, so repeated resolutions in one process must pay it only once.
16
+ # File identity in the key makes rotation (re-seal) self-invalidating.
17
+ _unseal_cache: dict[tuple[str, str, int, int], dict[str, str]] = {}
18
+
19
+
20
+ def clear_unseal_cache() -> None:
21
+ """Drop cached unsealed secrets (wired into config.clear_secret_caches)."""
22
+ _unseal_cache.clear()
23
+
14
24
 
15
25
  def resolve_sealed_keys_path() -> Path:
16
26
  """Resolve the encrypted secret bundle path under the OpenClaw home directory."""
@@ -35,11 +45,12 @@ def seal_keys(boot_key: str, secrets: dict[str, str]) -> Path:
35
45
  encrypted = encrypt_secret_material(payload, master_key=boot_key)
36
46
  path = resolve_sealed_keys_path()
37
47
  atomic_write_text(path, encrypted, mode=0o600)
48
+ clear_unseal_cache()
38
49
  return path
39
50
 
40
51
 
41
52
  def unseal_keys(boot_key: str) -> dict[str, str]:
42
- """Decrypt all secrets from the sealed file."""
53
+ """Decrypt all secrets from the sealed file. Memoized by file identity."""
43
54
  if not boot_key.strip():
44
55
  return {}
45
56
 
@@ -47,7 +58,17 @@ def unseal_keys(boot_key: str) -> dict[str, str]:
47
58
  if not path.exists():
48
59
  return {}
49
60
 
50
- plaintext = decrypt_secret_material(path.read_text(encoding="utf-8"), master_key=boot_key)
61
+ try:
62
+ stat = path.stat()
63
+ except OSError:
64
+ return {}
65
+ cache_key = (boot_key, str(path), stat.st_mtime_ns, stat.st_size)
66
+ cached = _unseal_cache.get(cache_key)
67
+ if cached is not None:
68
+ return dict(cached)
69
+
70
+ raw_text = path.read_text(encoding="utf-8")
71
+ plaintext = decrypt_secret_material(raw_text, master_key=boot_key)
51
72
  try:
52
73
  payload = json.loads(plaintext)
53
74
  except json.JSONDecodeError as exc:
@@ -58,4 +79,39 @@ def unseal_keys(boot_key: str) -> dict[str, str]:
58
79
  for key, value in payload.items():
59
80
  if isinstance(key, str) and isinstance(value, str):
60
81
  secrets[key] = value
82
+ _maybe_migrate_envelope_kdf(boot_key, secrets, raw_text)
83
+ _unseal_cache.clear()
84
+ _unseal_cache[cache_key] = dict(secrets)
61
85
  return secrets
86
+
87
+
88
+ def _maybe_migrate_envelope_kdf(boot_key: str, secrets: dict[str, str], raw_text: str) -> None:
89
+ """Lazily re-seal an argon2id file as hkdf-sha256 after a successful unseal.
90
+
91
+ Best-effort: any failure leaves the (still readable) argon2id file in
92
+ place. The boot key is machine-generated high entropy, so hkdf loses no
93
+ security while dropping ~1s of KDF per cold process. Rollback caveat:
94
+ pre-hkdf runtimes cannot read the rewritten file — kill switch is
95
+ AGENT_WALLET_ENVELOPE_KDF_MIGRATION=0.
96
+ """
97
+ from agent_wallet.config import envelope_kdf_migration_enabled
98
+ from agent_wallet.encrypted_storage import (
99
+ KDF_ARGON2ID,
100
+ KDF_HKDF_SHA256,
101
+ _default_write_kdf,
102
+ envelope_kdf,
103
+ )
104
+
105
+ try:
106
+ if not envelope_kdf_migration_enabled():
107
+ return
108
+ if _default_write_kdf() == KDF_ARGON2ID:
109
+ return # forced-argon2id installs must not rewrite in place forever
110
+ if envelope_kdf(raw_text) != KDF_ARGON2ID:
111
+ return
112
+ payload = json.dumps(secrets, indent=2)
113
+ encrypted = encrypt_secret_material(payload, master_key=boot_key, kdf=KDF_HKDF_SHA256)
114
+ atomic_write_text(resolve_sealed_keys_path(), encrypted, mode=0o600)
115
+ clear_unseal_cache()
116
+ except Exception:
117
+ pass
@@ -52,13 +52,27 @@ def resolve_user_wallet_path(user_id: str, network: str | None = None) -> Path:
52
52
  return user_dir / f"solana-{effective_network}-agent.json"
53
53
 
54
54
 
55
- def _user_wallet_metadata(user_id: str, address: str, network: str | None = None) -> dict[str, str]:
55
+ def _user_wallet_metadata(
56
+ user_id: str,
57
+ address: str,
58
+ network: str | None = None,
59
+ key_scope: str | None = None,
60
+ ) -> dict[str, str]:
56
61
  effective_network = normalize_solana_network(network or settings.solana_network)
57
- return {
62
+ metadata = {
58
63
  "address": address,
59
64
  "user_id": user_id,
60
65
  "network": effective_network,
61
66
  }
67
+ # Record which master-key scope encrypted this envelope so the loader can
68
+ # try the matching candidate first instead of paying a wasted KDF on the
69
+ # wrong one. Writes through _resolve_user_wallet_master_key follow
70
+ # use_per_user_key_derivation(), so that is the accurate default.
71
+ effective_scope = key_scope or (
72
+ "per-user-derived" if use_per_user_key_derivation() else "global-master"
73
+ )
74
+ metadata["key_scope"] = effective_scope
75
+ return metadata
62
76
 
63
77
 
64
78
  def _resolve_effective_network(network: str | None = None) -> str:
@@ -126,12 +140,25 @@ def _load_user_wallet_secret_material(
126
140
  if not is_encrypted_wallet_payload(raw_text):
127
141
  return raw_text, "plaintext", None
128
142
 
129
- last_exc: WalletBackendError | None = None
130
- for key_scope, candidate in _candidate_user_wallet_master_keys(
143
+ candidates = _candidate_user_wallet_master_keys(
131
144
  user_id,
132
145
  network,
133
146
  raw_master_key=raw_master_key,
134
- ):
147
+ )
148
+ # Try the scope recorded at encryption time first: a mismatched candidate
149
+ # costs a full (failed) KDF before the right one is attempted. Files
150
+ # without the marker keep the legacy order.
151
+ try:
152
+ import json as _json
153
+
154
+ recorded_scope = (_json.loads(raw_text).get("metadata") or {}).get("key_scope")
155
+ except Exception:
156
+ recorded_scope = None
157
+ if isinstance(recorded_scope, str) and recorded_scope:
158
+ candidates.sort(key=lambda item: item[0] != recorded_scope)
159
+
160
+ last_exc: WalletBackendError | None = None
161
+ for key_scope, candidate in candidates:
135
162
  try:
136
163
  return (
137
164
  decrypt_secret_material(raw_text, master_key=candidate),
@@ -147,6 +174,58 @@ def _load_user_wallet_secret_material(
147
174
  )
148
175
 
149
176
 
177
+ def _maybe_migrate_wallet_envelope_kdf(
178
+ path: Path,
179
+ secret_material: str,
180
+ *,
181
+ user_id: str,
182
+ network: str,
183
+ key_scope: str | None,
184
+ address: str,
185
+ ) -> None:
186
+ """Lazily rewrite an argon2id wallet envelope as hkdf-sha256.
187
+
188
+ Re-encrypts with the SAME key scope that just decrypted the file, so this
189
+ never performs a scope migration through the back door. Best-effort: any
190
+ failure leaves the (still readable) argon2id file untouched. Kill switch:
191
+ AGENT_WALLET_ENVELOPE_KDF_MIGRATION=0.
192
+ """
193
+ from agent_wallet.config import envelope_kdf_migration_enabled
194
+ from agent_wallet.encrypted_storage import (
195
+ KDF_ARGON2ID,
196
+ _default_write_kdf,
197
+ envelope_kdf,
198
+ )
199
+
200
+ try:
201
+ if not envelope_kdf_migration_enabled():
202
+ return
203
+ if _default_write_kdf() == KDF_ARGON2ID:
204
+ return # forced-argon2id installs must not rewrite in place forever
205
+ if key_scope not in {"per-user-derived", "global-master"}:
206
+ return
207
+ if envelope_kdf(path.read_text(encoding="utf-8")) != KDF_ARGON2ID:
208
+ return
209
+ if key_scope == "per-user-derived":
210
+ master_key = _derive_user_scoped_key(
211
+ resolve_wallet_master_key(), user_id=user_id, network=network
212
+ )
213
+ else:
214
+ master_key = resolve_wallet_master_key()
215
+ if not master_key.strip():
216
+ return
217
+ write_encrypted_wallet_file(
218
+ path,
219
+ secret_material,
220
+ master_key=master_key,
221
+ metadata=_user_wallet_metadata(
222
+ user_id, address, network=network, key_scope=key_scope
223
+ ),
224
+ )
225
+ except Exception:
226
+ pass
227
+
228
+
150
229
  def ensure_user_solana_wallet(
151
230
  user_id: str,
152
231
  network: str | None = None,
@@ -206,6 +285,15 @@ def ensure_user_solana_wallet(
206
285
  metadata=_user_wallet_metadata(user_id, signer.address, network=effective_network),
207
286
  )
208
287
  key_scope = "per-user-derived"
288
+ elif storage_format == "encrypted":
289
+ _maybe_migrate_wallet_envelope_kdf(
290
+ path,
291
+ secret_material,
292
+ user_id=user_id,
293
+ network=effective_network,
294
+ key_scope=key_scope,
295
+ address=signer.address,
296
+ )
209
297
  return {
210
298
  "user_id": user_id,
211
299
  "address": signer.address,
@@ -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.64",
5
+ "version": "0.1.65",
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.64"
7
+ version = "0.1.65"
8
8
  description = "Plugin-friendly wallet backend for OpenClaw agents"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -19,15 +19,23 @@ INCLUDED_ROOT_FILES = [
19
19
  "LICENSE",
20
20
  "README.md",
21
21
  "RELEASING.md",
22
+ "VERSION",
22
23
  "install-from-github.sh",
23
24
  "requirements.txt",
24
25
  "setup.sh",
25
26
  ]
27
+ # Must cover every setup.sh require_path and mirror the npm package `files`
28
+ # allowlist (RELEASING.md "What Ships") — the bundle is the same runtime users
29
+ # get from npx, just delivered as a GitHub Release asset.
26
30
  INCLUDED_TOP_LEVEL_DIRS = [
27
- "codex",
31
+ ".claude-plugin",
28
32
  ".openclaw",
29
- "agent-a2a-gateway",
30
33
  "agent-wallet",
34
+ "bin",
35
+ "claude-code",
36
+ "codex",
37
+ "hermes",
38
+ "scripts",
31
39
  "wdk-btc-wallet",
32
40
  "wdk-evm-wallet",
33
41
  ]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-wallet",
3
3
  "displayName": "Agent Wallet",
4
- "version": "0.1.64",
4
+ "version": "0.1.65",
5
5
  "description": "Claude Code bridge for the existing AgentLayer wallet runtime. Connects to Solana, Bitcoin, and EVM wallets without creating a new one.",
6
6
  "author": {
7
7
  "name": "AgentLayer"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-wallet",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "description": "Codex plugin bridge for the AgentLayer wallet runtime.",
5
5
  "author": {
6
6
  "name": "AgentLayer"
@@ -78,6 +78,9 @@ APPROVAL_CONTEXT_MISSING_MESSAGE = (
78
78
  "operation again, wait for explicit user confirmation, then retry execute. Do not ask the "
79
79
  "user for a manual approval token."
80
80
  )
81
+ # Tools served by the long-lived resident read worker instead of a cold CLI
82
+ # subprocess. Seeded with the legacy floor; _build_tool_definitions() extends
83
+ # it with every adapter-declared read-only tool at server start.
81
84
  RESIDENT_READ_ONLY_TOOLS = {
82
85
  "get_wallet_balance",
83
86
  "get_wallet_portfolio",
@@ -622,20 +625,31 @@ def _call_wallet_cli(command: str, extra_args: list[str]) -> dict[str, Any]:
622
625
  raise WalletCliError(f"agent-wallet CLI returned invalid JSON: {exc}") from exc
623
626
 
624
627
 
625
- def _invoke_tool(tool_name: str, arguments: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
626
- return _call_wallet_cli(
627
- "invoke",
628
- [
629
- "--user-id",
630
- _user_id(),
631
- "--tool",
632
- tool_name,
633
- "--arguments-json",
634
- json.dumps(arguments),
635
- "--config-json",
636
- json.dumps(config),
637
- ],
638
- )
628
+ def _invoke_tool(
629
+ tool_name: str,
630
+ arguments: dict[str, Any],
631
+ config: dict[str, Any],
632
+ *,
633
+ approval_summary: dict[str, Any] | None = None,
634
+ approval_mainnet_confirmed: bool = False,
635
+ ) -> dict[str, Any]:
636
+ extra_args = [
637
+ "--user-id",
638
+ _user_id(),
639
+ "--tool",
640
+ tool_name,
641
+ "--arguments-json",
642
+ json.dumps(arguments),
643
+ "--config-json",
644
+ json.dumps(config),
645
+ ]
646
+ if approval_summary is not None:
647
+ # The invoke subprocess mints the approval token itself, so an execute
648
+ # costs one cold start instead of two (issue-approval + invoke).
649
+ extra_args.extend(["--approval-summary-json", json.dumps(approval_summary)])
650
+ if approval_mainnet_confirmed:
651
+ extra_args.append("--approval-mainnet-confirmed")
652
+ return _call_wallet_cli("invoke", extra_args)
639
653
 
640
654
 
641
655
  class _ResidentReadWorker:
@@ -943,33 +957,16 @@ def _prewarm_resident_read_worker() -> None:
943
957
  ).start()
944
958
 
945
959
 
946
- def _issue_approval_token(
947
- tool_name: str,
948
- config: dict[str, Any],
949
- preview_payload: dict[str, Any],
950
- ) -> str:
960
+ def _approval_summary_for_preview(tool_name: str, preview_payload: dict[str, Any]) -> dict[str, Any]:
961
+ """Build the digest-bound confirmation summary the invoke subprocess will
962
+ mint an approval token from (in-process, replacing the old standalone
963
+ issue-approval subprocess round trip)."""
951
964
  summary = preview_payload.get("confirmation_summary")
952
965
  if not isinstance(summary, dict):
953
966
  raise RuntimeError(f"No confirmation_summary available for {tool_name}.")
954
967
  summary_for_token = dict(summary)
955
968
  summary_for_token["_preview_digest"] = _preview_digest(preview_payload)
956
- extra_args = [
957
- "--user-id",
958
- _user_id(),
959
- "--tool",
960
- tool_name,
961
- "--summary-json",
962
- json.dumps(summary_for_token),
963
- "--config-json",
964
- json.dumps(config),
965
- ]
966
- if preview_payload.get("is_mainnet") is True:
967
- extra_args.append("--mainnet-confirmed")
968
- payload = _call_wallet_cli("issue-approval", extra_args)
969
- token = str(payload.get("approval_token") or "").strip()
970
- if not token:
971
- raise RuntimeError(f"issue-approval did not return an approval_token for {tool_name}.")
972
- return token
969
+ return summary_for_token
973
970
 
974
971
 
975
972
  def _invoke_resident_read_tool(tool_name: str, arguments: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
@@ -1041,10 +1038,17 @@ def _attach_approval_for_execute(
1041
1038
  tool_name: str,
1042
1039
  config: dict[str, Any],
1043
1040
  effective_params: dict[str, Any],
1044
- ) -> dict[str, Any] | None:
1041
+ ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
1042
+ """Prepare approval context for an execute call.
1043
+
1044
+ Returns ``(used_cache, approval_args)``: ``used_cache`` is the consumed
1045
+ bridge-managed preview (if any); ``approval_args`` carries the
1046
+ digest-bound summary the invoke subprocess mints its own token from,
1047
+ so no separate issue-approval subprocess is spawned.
1048
+ """
1045
1049
  mode = str(effective_params.get("mode") or "")
1046
1050
  if mode not in {"execute", "intent_execute"}:
1047
- return None
1051
+ return None, None
1048
1052
  if tool_name == "swap_solana_tokens" and mode == "execute":
1049
1053
  raise RuntimeError(
1050
1054
  "Legacy exact-preview execute is disabled for Solana Jupiter swaps in Codex. "
@@ -1053,19 +1057,22 @@ def _attach_approval_for_execute(
1053
1057
  cached = _latest_cached_preview(_user_id(), tool_name)
1054
1058
  if cached and isinstance(cached.get("preview"), dict):
1055
1059
  preview = cached["preview"]
1056
- effective_params["approval_token"] = _issue_approval_token(tool_name, config, preview)
1060
+ approval_args = {
1061
+ "summary": _approval_summary_for_preview(tool_name, preview),
1062
+ "mainnet_confirmed": preview.get("is_mainnet") is True,
1063
+ }
1057
1064
  if _requires_approved_preview_payload(tool_name, effective_params):
1058
1065
  effective_params["_approved_preview"] = preview
1059
- return cached
1066
+ return cached, approval_args
1060
1067
  approval_token = str(effective_params.get("approval_token") or "").strip()
1061
1068
  if approval_token and _requires_approved_preview_payload(tool_name, effective_params):
1062
1069
  cached_preview = _cached_preview_for_token(_user_id(), tool_name, approval_token)
1063
1070
  if cached_preview is not None and "_approved_preview" not in effective_params:
1064
1071
  effective_params["_approved_preview"] = cached_preview
1065
1072
  if effective_params.get("approval_token"):
1066
- return None
1073
+ return None, None
1067
1074
  if _should_let_backend_authorize_autonomous_execution(tool_name, effective_params, config):
1068
- return None
1075
+ return None, None
1069
1076
  raise RuntimeError(APPROVAL_CONTEXT_MISSING_MESSAGE)
1070
1077
 
1071
1078
 
@@ -1242,6 +1249,13 @@ def _build_tool_definitions() -> list[dict[str, Any]]:
1242
1249
  merged.setdefault(spec["name"], spec)
1243
1250
  for spec in merged.values():
1244
1251
  spec["input_schema"] = _sanitize_schema(spec["input_schema"])
1252
+ if spec.get("read_only") is True:
1253
+ # Route every adapter-declared read-only tool through the resident
1254
+ # read worker: reads then cost one warm request instead of a cold
1255
+ # interpreter boot + onboarding per call. The worker enforces the
1256
+ # same read_only contract on its side, and transport failures fall
1257
+ # back to the cold subprocess path.
1258
+ RESIDENT_READ_ONLY_TOOLS.add(spec["name"])
1245
1259
  if spec.get("read_only") is False:
1246
1260
  spec["description"] = (
1247
1261
  f"{spec['description']} Preview first when supported. Execute reuses cached "
@@ -1388,9 +1402,15 @@ def _invoke_wallet_tool_blocking(
1388
1402
  call never freezes the MCP server (tools/list, read-only calls, and
1389
1403
  cancellation stay responsive).
1390
1404
  """
1391
- used_cache = _attach_approval_for_execute(tool_name, config, effective_params)
1405
+ used_cache, approval_args = _attach_approval_for_execute(tool_name, config, effective_params)
1392
1406
  try:
1393
- payload = _invoke_tool(tool_name, effective_params, config)
1407
+ payload = _invoke_tool(
1408
+ tool_name,
1409
+ effective_params,
1410
+ config,
1411
+ approval_summary=(approval_args or {}).get("summary"),
1412
+ approval_mainnet_confirmed=bool((approval_args or {}).get("mainnet_confirmed")),
1413
+ )
1394
1414
  except Exception as exc:
1395
1415
  raise _normalize_approval_context_error(exc) from exc
1396
1416
  _cache_preview_for_approval(_user_id(), tool_name, payload)
@@ -1,5 +1,5 @@
1
1
  name: agent-wallet
2
- version: 0.1.64
2
+ version: 0.1.65
3
3
  description: Thin Hermes Agent bridge to the existing AgentLayer/OpenClaw wallet backend
4
4
  provides_tools:
5
5
  - agent_wallet_tools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayer.tech/wallet",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "description": "NPM installer for the OpenClaw Agent Wallet local runtime.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wdk-btc-wallet",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Separate BTC-only wallet service built on Tether WDK.",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wdk-evm-wallet",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Separate EVM wallet service built on Tether WDK.",