@agentlayer.tech/wallet 0.1.64 → 0.1.66

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.66",
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.66",
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.66
@@ -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.66"
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"}:
@@ -3928,6 +3928,9 @@ class OpenClawWalletAdapter:
3928
3928
  raise WalletBackendError("text_body must be a string when provided.")
3929
3929
  if not isinstance(purpose, str) or not purpose.strip():
3930
3930
  raise WalletBackendError("purpose is required.")
3931
+ approved_preview = args.get("_approved_preview")
3932
+ if approved_preview is not None and not isinstance(approved_preview, dict):
3933
+ raise WalletBackendError("_approved_preview must be an object when provided.")
3931
3934
  data = await x402.pay_and_fetch(
3932
3935
  backend=active_backend,
3933
3936
  url=url.strip(),
@@ -3936,6 +3939,7 @@ class OpenClawWalletAdapter:
3936
3939
  query=query,
3937
3940
  json_body=json_body,
3938
3941
  text_body=text_body,
3942
+ approved_preview=approved_preview,
3939
3943
  )
3940
3944
  data["purpose"] = purpose.strip()
3941
3945
  data = self._annotate_x402_payload(data, mode="execute")
@@ -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: