@agentlayer.tech/wallet 0.1.58 → 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/CHANGELOG.md +30 -0
- package/README.md +1 -1
- package/VERSION +1 -1
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/boot_key_migration.py +158 -0
- package/agent-wallet/agent_wallet/boot_key_recovery.py +46 -0
- package/agent-wallet/agent_wallet/config.py +63 -1
- package/agent-wallet/agent_wallet/encrypted_storage.py +77 -9
- package/agent-wallet/agent_wallet/keystore.py +328 -0
- package/agent-wallet/agent_wallet/openclaw_cli.py +83 -4
- package/agent-wallet/agent_wallet/openclaw_runtime.py +20 -0
- 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/bin/openclaw-agent-wallet.mjs +73 -4
- 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
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Cross-platform, dependency-free secret storage for the wallet boot key.
|
|
2
|
+
|
|
3
|
+
Backends shell out to tools already present on each desktop OS:
|
|
4
|
+
- macOS: /usr/bin/security (generic password in the login keychain)
|
|
5
|
+
- Windows: powershell DPAPI (ConvertFrom/ConvertTo-SecureString), per-user+machine
|
|
6
|
+
- Linux: secret-tool (libsecret) iff a Secret Service is reachable
|
|
7
|
+
- fallback: a 0600 plaintext file (current behavior) — never chosen over a
|
|
8
|
+
working keystore.
|
|
9
|
+
|
|
10
|
+
The store holds ONE install-level secret (the boot key). See
|
|
11
|
+
BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
from agent_wallet.config import resolve_openclaw_home
|
|
24
|
+
from agent_wallet.file_ops import atomic_write_text, chmod_if_exists
|
|
25
|
+
|
|
26
|
+
KEYSTORE_SERVICE = "ai.agentlayer.wallet"
|
|
27
|
+
BOOT_KEY_ITEM = "boot_key"
|
|
28
|
+
_PROBE_ITEM = "__probe__"
|
|
29
|
+
_KEYSTORE_BACKEND_ENV = "AGENT_WALLET_KEYSTORE_BACKEND"
|
|
30
|
+
|
|
31
|
+
_SECURITY_BIN = "/usr/bin/security"
|
|
32
|
+
_SUBPROCESS_TIMEOUT = 10.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _service() -> str:
|
|
36
|
+
"""Keychain/Secret-Service service name. Overridable so tests never touch the
|
|
37
|
+
real shared slot (the OS keychain is global, not scoped to OPENCLAW_HOME)."""
|
|
38
|
+
return os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip() or KEYSTORE_SERVICE
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _backend_preference() -> str:
|
|
42
|
+
"""Selected keystore backend.
|
|
43
|
+
|
|
44
|
+
auto (default) prefers the native OS keystore (macOS Keychain / Windows DPAPI /
|
|
45
|
+
Linux Secret Service) and falls back to a 0600 plaintext file. macOS Keychain
|
|
46
|
+
is prompt-free because set() writes with `-A` (open ACL, no partition-list).
|
|
47
|
+
Override with AGENT_WALLET_KEYSTORE_BACKEND=plaintext|macos-keychain|native|...
|
|
48
|
+
"""
|
|
49
|
+
return os.getenv(_KEYSTORE_BACKEND_ENV, "auto").strip().lower()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class KeyStoreError(Exception):
|
|
53
|
+
"""Raised when a keystore backend operation fails unexpectedly."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@runtime_checkable
|
|
57
|
+
class KeyStore(Protocol):
|
|
58
|
+
backend_id: str
|
|
59
|
+
|
|
60
|
+
def available(self) -> bool: ...
|
|
61
|
+
def get(self, name: str) -> str | None: ...
|
|
62
|
+
def set(self, name: str, value: str) -> None: ...
|
|
63
|
+
def delete(self, name: str) -> None: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _timeout_output(value: str | bytes | None) -> str:
|
|
67
|
+
if value is None:
|
|
68
|
+
return ""
|
|
69
|
+
if isinstance(value, bytes):
|
|
70
|
+
return value.decode(errors="replace")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _run(
|
|
75
|
+
argv: list[str],
|
|
76
|
+
*,
|
|
77
|
+
input_text: str | None = None,
|
|
78
|
+
timeout: float = _SUBPROCESS_TIMEOUT,
|
|
79
|
+
) -> subprocess.CompletedProcess[str]:
|
|
80
|
+
kwargs: dict[str, object] = {
|
|
81
|
+
"capture_output": True,
|
|
82
|
+
"text": True,
|
|
83
|
+
"timeout": timeout,
|
|
84
|
+
"check": False,
|
|
85
|
+
}
|
|
86
|
+
if input_text is None:
|
|
87
|
+
kwargs["stdin"] = subprocess.DEVNULL
|
|
88
|
+
else:
|
|
89
|
+
kwargs["input"] = input_text
|
|
90
|
+
try:
|
|
91
|
+
return subprocess.run(argv, **kwargs)
|
|
92
|
+
except subprocess.TimeoutExpired as exc:
|
|
93
|
+
return subprocess.CompletedProcess(
|
|
94
|
+
argv,
|
|
95
|
+
124,
|
|
96
|
+
_timeout_output(exc.stdout),
|
|
97
|
+
_timeout_output(exc.stderr) or f"timed out after {timeout:g}s",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MacKeychainStore:
|
|
102
|
+
backend_id = "macos-keychain"
|
|
103
|
+
|
|
104
|
+
def available(self) -> bool:
|
|
105
|
+
return platform.system() == "Darwin" and Path(_SECURITY_BIN).exists()
|
|
106
|
+
|
|
107
|
+
def get(self, name: str) -> str | None:
|
|
108
|
+
proc = _run([_SECURITY_BIN, "find-generic-password", "-s", _service(), "-a", name, "-w"])
|
|
109
|
+
if proc.returncode != 0:
|
|
110
|
+
return None # item not found (44) or other non-fatal lookup miss
|
|
111
|
+
value = proc.stdout.rstrip("\n")
|
|
112
|
+
return value or None
|
|
113
|
+
|
|
114
|
+
def set(self, name: str, value: str) -> None:
|
|
115
|
+
# Recreate the item fresh with an open ACL. This is the ONLY reliably
|
|
116
|
+
# prompt-free way to write a login-keychain item non-interactively:
|
|
117
|
+
# -A -> accessible to any application without a prompt, and it
|
|
118
|
+
# bypasses the Sierra+ partition-list mechanism entirely.
|
|
119
|
+
# delete+add -> never -U-update an existing item; updating one created
|
|
120
|
+
# with a different ACL (or setting its partition list)
|
|
121
|
+
# pops a GUI keychain-password dialog in the background.
|
|
122
|
+
# Trade-off: any process running as this user can read the key without a
|
|
123
|
+
# prompt — the same runtime exposure as a 0600 file. At-rest protection
|
|
124
|
+
# (backups, synced home dirs, a stolen disk) is fully preserved.
|
|
125
|
+
_run([_SECURITY_BIN, "delete-generic-password", "-s", _service(), "-a", name])
|
|
126
|
+
proc = _run([
|
|
127
|
+
_SECURITY_BIN, "add-generic-password",
|
|
128
|
+
"-s", _service(), "-a", name,
|
|
129
|
+
"-w", value, "-A",
|
|
130
|
+
])
|
|
131
|
+
if proc.returncode != 0:
|
|
132
|
+
raise KeyStoreError(f"security add-generic-password failed: {proc.stderr.strip()}")
|
|
133
|
+
|
|
134
|
+
def delete(self, name: str) -> None:
|
|
135
|
+
_run([_SECURITY_BIN, "delete-generic-password", "-s", _service(), "-a", name])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class WindowsDpapiStore:
|
|
139
|
+
backend_id = "windows-dpapi"
|
|
140
|
+
|
|
141
|
+
def _blob_path(self, name: str) -> Path:
|
|
142
|
+
return resolve_openclaw_home() / "keystore" / f"{name}.dpapi"
|
|
143
|
+
|
|
144
|
+
def available(self) -> bool:
|
|
145
|
+
return platform.system() == "Windows" and shutil.which("powershell") is not None
|
|
146
|
+
|
|
147
|
+
def get(self, name: str) -> str | None:
|
|
148
|
+
path = self._blob_path(name)
|
|
149
|
+
if not path.exists():
|
|
150
|
+
return None
|
|
151
|
+
blob = path.read_text(encoding="utf-8").strip()
|
|
152
|
+
if not blob:
|
|
153
|
+
return None
|
|
154
|
+
script = (
|
|
155
|
+
"$ErrorActionPreference='Stop';"
|
|
156
|
+
"$b=[Console]::In.ReadToEnd().Trim();"
|
|
157
|
+
"$ss=ConvertTo-SecureString $b;"
|
|
158
|
+
"$p=[Runtime.InteropServices.Marshal]::PtrToStringUni("
|
|
159
|
+
"[Runtime.InteropServices.Marshal]::SecureStringToBSTR($ss));"
|
|
160
|
+
"[Console]::Out.Write($p)"
|
|
161
|
+
)
|
|
162
|
+
proc = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], input_text=blob)
|
|
163
|
+
if proc.returncode != 0:
|
|
164
|
+
return None
|
|
165
|
+
return proc.stdout or None
|
|
166
|
+
|
|
167
|
+
def set(self, name: str, value: str) -> None:
|
|
168
|
+
script = (
|
|
169
|
+
"$ErrorActionPreference='Stop';"
|
|
170
|
+
"$v=[Console]::In.ReadToEnd();"
|
|
171
|
+
"$ss=ConvertTo-SecureString $v -AsPlainText -Force;"
|
|
172
|
+
"[Console]::Out.Write((ConvertFrom-SecureString $ss))"
|
|
173
|
+
)
|
|
174
|
+
proc = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], input_text=value)
|
|
175
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
176
|
+
raise KeyStoreError(f"DPAPI ConvertFrom-SecureString failed: {proc.stderr.strip()}")
|
|
177
|
+
atomic_write_text(self._blob_path(name), proc.stdout.strip() + "\n", mode=0o600)
|
|
178
|
+
|
|
179
|
+
def delete(self, name: str) -> None:
|
|
180
|
+
path = self._blob_path(name)
|
|
181
|
+
try:
|
|
182
|
+
path.unlink()
|
|
183
|
+
except FileNotFoundError:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class LinuxSecretServiceStore:
|
|
188
|
+
backend_id = "linux-secretservice"
|
|
189
|
+
|
|
190
|
+
def available(self) -> bool:
|
|
191
|
+
if platform.system() != "Linux" or shutil.which("secret-tool") is None:
|
|
192
|
+
return False
|
|
193
|
+
# A probe lookup succeeds (rc 0/1) only when a Secret Service answers;
|
|
194
|
+
# a missing/unreachable service errors out (rc >1) or times out.
|
|
195
|
+
try:
|
|
196
|
+
proc = _run(["secret-tool", "lookup", "service", _service(), "account", "__probe__"])
|
|
197
|
+
except subprocess.TimeoutExpired:
|
|
198
|
+
return False
|
|
199
|
+
return proc.returncode in (0, 1)
|
|
200
|
+
|
|
201
|
+
def get(self, name: str) -> str | None:
|
|
202
|
+
proc = _run(["secret-tool", "lookup", "service", _service(), "account", name])
|
|
203
|
+
if proc.returncode != 0:
|
|
204
|
+
return None
|
|
205
|
+
value = proc.stdout.rstrip("\n")
|
|
206
|
+
return value or None
|
|
207
|
+
|
|
208
|
+
def set(self, name: str, value: str) -> None:
|
|
209
|
+
proc = _run(
|
|
210
|
+
["secret-tool", "store", "--label", f"{_service()} {name}",
|
|
211
|
+
"service", _service(), "account", name],
|
|
212
|
+
input_text=value,
|
|
213
|
+
)
|
|
214
|
+
if proc.returncode != 0:
|
|
215
|
+
raise KeyStoreError(f"secret-tool store failed: {proc.stderr.strip()}")
|
|
216
|
+
|
|
217
|
+
def delete(self, name: str) -> None:
|
|
218
|
+
_run(["secret-tool", "clear", "service", _service(), "account", name])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class PlaintextFileStore:
|
|
222
|
+
"""Fallback: a 0600 file under OPENCLAW_HOME/keystore. Always available."""
|
|
223
|
+
|
|
224
|
+
backend_id = "plaintext-file"
|
|
225
|
+
|
|
226
|
+
def _path(self, name: str) -> Path:
|
|
227
|
+
return resolve_openclaw_home() / "keystore" / f"{name}.plaintext"
|
|
228
|
+
|
|
229
|
+
def available(self) -> bool:
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
def get(self, name: str) -> str | None:
|
|
233
|
+
path = self._path(name)
|
|
234
|
+
if not path.exists():
|
|
235
|
+
return None
|
|
236
|
+
chmod_if_exists(path)
|
|
237
|
+
value = path.read_text(encoding="utf-8").strip()
|
|
238
|
+
return value or None
|
|
239
|
+
|
|
240
|
+
def set(self, name: str, value: str) -> None:
|
|
241
|
+
atomic_write_text(self._path(name), value.strip() + "\n", mode=0o600)
|
|
242
|
+
|
|
243
|
+
def delete(self, name: str) -> None:
|
|
244
|
+
try:
|
|
245
|
+
self._path(name).unlink()
|
|
246
|
+
except FileNotFoundError:
|
|
247
|
+
pass
|
|
248
|
+
|
|
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
|
+
|
|
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:
|
|
276
|
+
"""Return the first available OS-native backend, else the plaintext fallback."""
|
|
277
|
+
preference = _backend_preference()
|
|
278
|
+
if preference in {"plain", "plaintext", "plaintext-file", "file"}:
|
|
279
|
+
return PlaintextFileStore()
|
|
280
|
+
|
|
281
|
+
candidates: list[KeyStore]
|
|
282
|
+
if preference in {"macos", "macos-keychain", "keychain"}:
|
|
283
|
+
candidates = [MacKeychainStore()]
|
|
284
|
+
elif preference in {"windows", "windows-dpapi", "dpapi"}:
|
|
285
|
+
candidates = [WindowsDpapiStore()]
|
|
286
|
+
elif preference in {"linux", "linux-secretservice", "secretservice"}:
|
|
287
|
+
candidates = [LinuxSecretServiceStore()]
|
|
288
|
+
elif preference == "native":
|
|
289
|
+
candidates = [MacKeychainStore(), WindowsDpapiStore(), LinuxSecretServiceStore()]
|
|
290
|
+
else:
|
|
291
|
+
# Auto: native OS keystore first, plaintext fallback last. macOS Keychain
|
|
292
|
+
# is prompt-free now that set() uses `-A` (open ACL, no partition-list) and
|
|
293
|
+
# _backend_usable falls back gracefully if a session still can't authorize.
|
|
294
|
+
candidates = [MacKeychainStore(), WindowsDpapiStore(), LinuxSecretServiceStore()]
|
|
295
|
+
|
|
296
|
+
for candidate in candidates:
|
|
297
|
+
try:
|
|
298
|
+
if candidate.available() and _backend_usable(candidate):
|
|
299
|
+
return candidate
|
|
300
|
+
except Exception:
|
|
301
|
+
continue
|
|
302
|
+
return PlaintextFileStore()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _backend_usable(candidate: KeyStore) -> bool:
|
|
306
|
+
"""Return true when a native backend can read the live key or write safely.
|
|
307
|
+
|
|
308
|
+
A desktop keystore binary can exist while the session cannot authorize
|
|
309
|
+
non-interactive writes. Treat that as unavailable so install/migration uses
|
|
310
|
+
the bounded fallback instead of hanging or repeatedly failing.
|
|
311
|
+
"""
|
|
312
|
+
try:
|
|
313
|
+
if candidate.get(BOOT_KEY_ITEM):
|
|
314
|
+
return True
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
probe_value = "ok"
|
|
319
|
+
try:
|
|
320
|
+
candidate.set(_PROBE_ITEM, probe_value)
|
|
321
|
+
return candidate.get(_PROBE_ITEM) == probe_value
|
|
322
|
+
except Exception:
|
|
323
|
+
return False
|
|
324
|
+
finally:
|
|
325
|
+
try:
|
|
326
|
+
candidate.delete(_PROBE_ITEM)
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
@@ -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)
|
|
@@ -582,6 +632,15 @@ def main() -> int:
|
|
|
582
632
|
read_worker_parser.add_argument("--user-id", required=True)
|
|
583
633
|
read_worker_parser.add_argument("--config-json", default="{}")
|
|
584
634
|
|
|
635
|
+
subparsers.add_parser("boot-key-export")
|
|
636
|
+
|
|
637
|
+
boot_key_import_parser = subparsers.add_parser("boot-key-import")
|
|
638
|
+
boot_key_import_parser.add_argument(
|
|
639
|
+
"--key-stdin",
|
|
640
|
+
action="store_true",
|
|
641
|
+
help="Read the boot key to import from stdin.",
|
|
642
|
+
)
|
|
643
|
+
|
|
585
644
|
args = parser.parse_args()
|
|
586
645
|
|
|
587
646
|
try:
|
|
@@ -705,13 +764,33 @@ def main() -> int:
|
|
|
705
764
|
payload = asyncio.run(_run_evm_wallet_lock(args.user_id, config))
|
|
706
765
|
elif args.command == "read-worker":
|
|
707
766
|
return asyncio.run(_serve_read_worker(args.user_id, config))
|
|
767
|
+
elif args.command == "boot-key-export":
|
|
768
|
+
from agent_wallet.boot_key_recovery import export_boot_key, export_warning
|
|
769
|
+
|
|
770
|
+
print(export_warning(), file=sys.stderr)
|
|
771
|
+
payload = {"boot_key": export_boot_key()}
|
|
772
|
+
elif args.command == "boot-key-import":
|
|
773
|
+
if not args.key_stdin:
|
|
774
|
+
raise WalletBackendError("boot-key-import requires --key-stdin.")
|
|
775
|
+
from agent_wallet.boot_key_recovery import import_boot_key
|
|
776
|
+
|
|
777
|
+
payload = import_boot_key(_read_stdin_secret("boot key"))
|
|
708
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)
|
|
709
783
|
payload = asyncio.run(
|
|
710
784
|
_run_invoke(
|
|
711
785
|
args.user_id,
|
|
712
786
|
args.tool,
|
|
713
787
|
_load_json(args.arguments_json),
|
|
714
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),
|
|
715
794
|
)
|
|
716
795
|
)
|
|
717
796
|
except Exception as exc:
|
|
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
8
|
from agent_wallet.approval import issue_approval_token
|
|
9
|
+
from agent_wallet.boot_key_migration import migrate_boot_key_to_keystore
|
|
9
10
|
from agent_wallet.btc_user_wallets import get_user_btc_wallet_binding
|
|
10
11
|
from agent_wallet.config import normalize_btc_network, normalize_evm_network, settings
|
|
11
12
|
from agent_wallet.evm_user_wallets import ensure_user_evm_wallet_ready
|
|
@@ -19,6 +20,24 @@ from agent_wallet.wallet_layer.base import AgentWalletBackend, WalletBackendErro
|
|
|
19
20
|
from agent_wallet.wallet_layer.wdk_evm import WdkEvmLocalWalletBackend
|
|
20
21
|
from agent_wallet.wallet_layer.wdk_btc import WdkBtcLocalWalletBackend
|
|
21
22
|
|
|
23
|
+
_boot_key_migration_done = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ensure_boot_key_migrated_once() -> None:
|
|
27
|
+
"""Run the boot-key keystore migration at most once per process. Never raises.
|
|
28
|
+
|
|
29
|
+
Best-effort: a failure here must never block wallet onboarding — the legacy
|
|
30
|
+
plaintext sources still resolve via ``resolve_boot_key``.
|
|
31
|
+
"""
|
|
32
|
+
global _boot_key_migration_done
|
|
33
|
+
if _boot_key_migration_done:
|
|
34
|
+
return
|
|
35
|
+
_boot_key_migration_done = True
|
|
36
|
+
try:
|
|
37
|
+
migrate_boot_key_to_keystore()
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
22
41
|
|
|
23
42
|
@dataclass(slots=True)
|
|
24
43
|
class OpenClawWalletRuntimeContext:
|
|
@@ -98,6 +117,7 @@ def onboard_openclaw_user_wallet(
|
|
|
98
117
|
wdk_evm_account_index: int | None = None,
|
|
99
118
|
) -> OpenClawWalletRuntimeContext:
|
|
100
119
|
"""Provision and assemble a runtime-ready wallet context for one OpenClaw user."""
|
|
120
|
+
ensure_boot_key_migrated_once()
|
|
101
121
|
backend_name = str(backend or settings.agent_wallet_backend).strip().lower()
|
|
102
122
|
effective_sign_only = True if read_only else (
|
|
103
123
|
settings.agent_wallet_sign_only if sign_only is None else sign_only
|
|
@@ -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
|