@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.
- package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +1 -1
- package/.openclaw/extensions/agent-wallet/package.json +1 -1
- package/VERSION +1 -1
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/config.py +44 -2
- package/agent-wallet/agent_wallet/encrypted_storage.py +77 -9
- package/agent-wallet/agent_wallet/keystore.py +25 -0
- package/agent-wallet/agent_wallet/openclaw_cli.py +63 -4
- package/agent-wallet/agent_wallet/sealed_keys.py +58 -2
- package/agent-wallet/agent_wallet/user_wallets.py +93 -5
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +1 -1
- package/agent-wallet/scripts/build_release_bundle.py +10 -2
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/server.py +64 -44
- package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
- package/package.json +1 -1
- package/wdk-btc-wallet/package.json +1 -1
- package/wdk-evm-wallet/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "agent-wallet",
|
|
3
3
|
"name": "Agent Wallet",
|
|
4
4
|
"description": "Official OpenClaw plugin bridge for the agent-wallet backends, including Solana, local BTC, and local EVM.",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.65",
|
|
6
6
|
"contracts": {
|
|
7
7
|
"tools": [
|
|
8
8
|
"agentlayer_autonomous_approve",
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.65
|
|
@@ -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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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":
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
5
|
+
"version": "0.1.65",
|
|
6
6
|
"skills": ["skills/wallet-operator"],
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
|
@@ -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
|
-
"
|
|
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.
|
|
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"
|
|
@@ -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(
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|
package/package.json
CHANGED