@agentlayer.tech/wallet 0.1.55 → 0.1.64
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 +81 -0
- package/README.md +1 -1
- package/VERSION +1 -1
- package/agent-wallet/.env.example +1 -0
- 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 +30 -1
- package/agent-wallet/agent_wallet/keystore.py +303 -0
- package/agent-wallet/agent_wallet/openclaw_cli.py +115 -39
- package/agent-wallet/agent_wallet/openclaw_runtime.py +28 -3
- package/agent-wallet/agent_wallet/providers/jupiter.py +34 -0
- package/agent-wallet/agent_wallet/user_wallets.py +46 -3
- package/agent-wallet/agent_wallet/wallet_layer/solana.py +34 -8
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +1 -1
- package/bin/openclaw-agent-wallet.mjs +73 -4
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
- package/claude-code/plugins/agent-wallet/commands/wallet-sol.md +9 -15
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/README.md +9 -0
- package/codex/plugins/agent-wallet/server.py +353 -4
- package/codex/plugins/agent-wallet/skills/wallet-sol/SKILL.md +29 -0
- 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,303 @@
|
|
|
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
|
+
def resolve_keystore() -> KeyStore:
|
|
251
|
+
"""Return the first available OS-native backend, else the plaintext fallback."""
|
|
252
|
+
preference = _backend_preference()
|
|
253
|
+
if preference in {"plain", "plaintext", "plaintext-file", "file"}:
|
|
254
|
+
return PlaintextFileStore()
|
|
255
|
+
|
|
256
|
+
candidates: list[KeyStore]
|
|
257
|
+
if preference in {"macos", "macos-keychain", "keychain"}:
|
|
258
|
+
candidates = [MacKeychainStore()]
|
|
259
|
+
elif preference in {"windows", "windows-dpapi", "dpapi"}:
|
|
260
|
+
candidates = [WindowsDpapiStore()]
|
|
261
|
+
elif preference in {"linux", "linux-secretservice", "secretservice"}:
|
|
262
|
+
candidates = [LinuxSecretServiceStore()]
|
|
263
|
+
elif preference == "native":
|
|
264
|
+
candidates = [MacKeychainStore(), WindowsDpapiStore(), LinuxSecretServiceStore()]
|
|
265
|
+
else:
|
|
266
|
+
# Auto: native OS keystore first, plaintext fallback last. macOS Keychain
|
|
267
|
+
# is prompt-free now that set() uses `-A` (open ACL, no partition-list) and
|
|
268
|
+
# _backend_usable falls back gracefully if a session still can't authorize.
|
|
269
|
+
candidates = [MacKeychainStore(), WindowsDpapiStore(), LinuxSecretServiceStore()]
|
|
270
|
+
|
|
271
|
+
for candidate in candidates:
|
|
272
|
+
try:
|
|
273
|
+
if candidate.available() and _backend_usable(candidate):
|
|
274
|
+
return candidate
|
|
275
|
+
except Exception:
|
|
276
|
+
continue
|
|
277
|
+
return PlaintextFileStore()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _backend_usable(candidate: KeyStore) -> bool:
|
|
281
|
+
"""Return true when a native backend can read the live key or write safely.
|
|
282
|
+
|
|
283
|
+
A desktop keystore binary can exist while the session cannot authorize
|
|
284
|
+
non-interactive writes. Treat that as unavailable so install/migration uses
|
|
285
|
+
the bounded fallback instead of hanging or repeatedly failing.
|
|
286
|
+
"""
|
|
287
|
+
try:
|
|
288
|
+
if candidate.get(BOOT_KEY_ITEM):
|
|
289
|
+
return True
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
probe_value = "ok"
|
|
294
|
+
try:
|
|
295
|
+
candidate.set(_PROBE_ITEM, probe_value)
|
|
296
|
+
return candidate.get(_PROBE_ITEM) == probe_value
|
|
297
|
+
except Exception:
|
|
298
|
+
return False
|
|
299
|
+
finally:
|
|
300
|
+
try:
|
|
301
|
+
candidate.delete(_PROBE_ITEM)
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
@@ -33,6 +33,12 @@ def _parse_csv(value: Any) -> str:
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
SECRET_CONFIG_KEYS = {"privateKey", "masterKey", "approvalSecret"}
|
|
36
|
+
READ_ONLY_WORKER_TOOLS = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"get_wallet_balance",
|
|
39
|
+
"get_wallet_portfolio",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
36
42
|
|
|
37
43
|
|
|
38
44
|
def _reject_secret_config_json(config: dict[str, Any]) -> None:
|
|
@@ -117,6 +123,9 @@ def _apply_config_overrides(config: dict[str, Any]) -> None:
|
|
|
117
123
|
if not allow_override and os.getenv(env_name, "").strip():
|
|
118
124
|
continue
|
|
119
125
|
os.environ[env_name] = text
|
|
126
|
+
from agent_wallet.config import reload_settings
|
|
127
|
+
|
|
128
|
+
reload_settings()
|
|
120
129
|
|
|
121
130
|
|
|
122
131
|
def _load_json(raw: str | None, default: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
@@ -138,10 +147,23 @@ def _read_stdin_secret(field_name: str) -> str:
|
|
|
138
147
|
async def _run_onboard(user_id: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
139
148
|
from agent_wallet.openclaw_runtime import onboard_openclaw_user_wallet
|
|
140
149
|
|
|
141
|
-
context =
|
|
150
|
+
context = _build_runtime_context(user_id, config)
|
|
151
|
+
return context.serializable_bundle()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _build_runtime_context(
|
|
155
|
+
user_id: str,
|
|
156
|
+
config: dict[str, Any],
|
|
157
|
+
*,
|
|
158
|
+
read_only: bool = False,
|
|
159
|
+
) -> Any:
|
|
160
|
+
from agent_wallet.openclaw_runtime import onboard_openclaw_user_wallet
|
|
161
|
+
|
|
162
|
+
return onboard_openclaw_user_wallet(
|
|
142
163
|
user_id,
|
|
143
164
|
backend=config.get("backend"),
|
|
144
165
|
sign_only=config.get("signOnly"),
|
|
166
|
+
read_only=read_only,
|
|
145
167
|
network=config.get("network"),
|
|
146
168
|
rpc_url=config.get("rpcUrl"),
|
|
147
169
|
wdk_btc_service_url=config.get("wdkBtcServiceUrl"),
|
|
@@ -151,7 +173,6 @@ async def _run_onboard(user_id: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
|
151
173
|
wdk_evm_wallet_id=config.get("wdkEvmWalletId"),
|
|
152
174
|
wdk_evm_account_index=config.get("wdkEvmAccountIndex"),
|
|
153
175
|
)
|
|
154
|
-
return context.serializable_bundle()
|
|
155
176
|
|
|
156
177
|
|
|
157
178
|
async def _run_invoke(
|
|
@@ -160,21 +181,7 @@ async def _run_invoke(
|
|
|
160
181
|
arguments: dict[str, Any],
|
|
161
182
|
config: dict[str, Any],
|
|
162
183
|
) -> dict[str, Any]:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
context = onboard_openclaw_user_wallet(
|
|
166
|
-
user_id,
|
|
167
|
-
backend=config.get("backend"),
|
|
168
|
-
sign_only=config.get("signOnly"),
|
|
169
|
-
network=config.get("network"),
|
|
170
|
-
rpc_url=config.get("rpcUrl"),
|
|
171
|
-
wdk_btc_service_url=config.get("wdkBtcServiceUrl"),
|
|
172
|
-
wdk_btc_wallet_id=config.get("wdkBtcWalletId"),
|
|
173
|
-
wdk_btc_account_index=config.get("wdkBtcAccountIndex"),
|
|
174
|
-
wdk_evm_service_url=config.get("wdkEvmServiceUrl"),
|
|
175
|
-
wdk_evm_wallet_id=config.get("wdkEvmWalletId"),
|
|
176
|
-
wdk_evm_account_index=config.get("wdkEvmAccountIndex"),
|
|
177
|
-
)
|
|
184
|
+
context = _build_runtime_context(user_id, config)
|
|
178
185
|
result = await context.adapter.invoke(tool_name, arguments)
|
|
179
186
|
return result.model_dump()
|
|
180
187
|
|
|
@@ -188,21 +195,7 @@ async def _run_issue_approval(
|
|
|
188
195
|
mainnet_confirmed: bool = False,
|
|
189
196
|
ttl_seconds: int | None = None,
|
|
190
197
|
) -> dict[str, Any]:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
context = onboard_openclaw_user_wallet(
|
|
194
|
-
user_id,
|
|
195
|
-
backend=config.get("backend"),
|
|
196
|
-
sign_only=config.get("signOnly"),
|
|
197
|
-
network=config.get("network"),
|
|
198
|
-
rpc_url=config.get("rpcUrl"),
|
|
199
|
-
wdk_btc_service_url=config.get("wdkBtcServiceUrl"),
|
|
200
|
-
wdk_btc_wallet_id=config.get("wdkBtcWalletId"),
|
|
201
|
-
wdk_btc_account_index=config.get("wdkBtcAccountIndex"),
|
|
202
|
-
wdk_evm_service_url=config.get("wdkEvmServiceUrl"),
|
|
203
|
-
wdk_evm_wallet_id=config.get("wdkEvmWalletId"),
|
|
204
|
-
wdk_evm_account_index=config.get("wdkEvmAccountIndex"),
|
|
205
|
-
)
|
|
198
|
+
context = _build_runtime_context(user_id, config)
|
|
206
199
|
token = context.issue_execute_approval(
|
|
207
200
|
tool_name=tool_name,
|
|
208
201
|
confirmation_summary=summary,
|
|
@@ -250,6 +243,69 @@ def _run_autonomous_permission(action: str, scope: str) -> dict[str, Any]:
|
|
|
250
243
|
raise WalletBackendError("action must be approve, revoke, or status.")
|
|
251
244
|
|
|
252
245
|
|
|
246
|
+
def _error_payload_for_exception(exc: Exception) -> dict[str, Any]:
|
|
247
|
+
payload: dict[str, Any] = {"ok": False, "error": str(exc)}
|
|
248
|
+
if isinstance(exc, WalletBackendError):
|
|
249
|
+
if exc.code:
|
|
250
|
+
payload["code"] = exc.code
|
|
251
|
+
if exc.details is not None:
|
|
252
|
+
payload["details"] = exc.details
|
|
253
|
+
return payload
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def _run_read_worker_tool(context: Any, request: dict[str, Any]) -> dict[str, Any]:
|
|
257
|
+
tool_name = str(request.get("tool") or "").strip()
|
|
258
|
+
if tool_name not in READ_ONLY_WORKER_TOOLS:
|
|
259
|
+
raise WalletBackendError(
|
|
260
|
+
f"read-worker only allows read-only tools: {', '.join(sorted(READ_ONLY_WORKER_TOOLS))}."
|
|
261
|
+
)
|
|
262
|
+
arguments = request.get("arguments") or {}
|
|
263
|
+
if not isinstance(arguments, dict):
|
|
264
|
+
raise WalletBackendError("read-worker request arguments must be a JSON object.")
|
|
265
|
+
result = await context.adapter.invoke(tool_name, arguments)
|
|
266
|
+
return result.model_dump()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def _serve_read_worker(user_id: str, config: dict[str, Any]) -> int:
|
|
270
|
+
context = _build_runtime_context(user_id, config, read_only=True)
|
|
271
|
+
while True:
|
|
272
|
+
line = sys.stdin.readline()
|
|
273
|
+
if not line:
|
|
274
|
+
break
|
|
275
|
+
text = line.strip()
|
|
276
|
+
if not text:
|
|
277
|
+
continue
|
|
278
|
+
request_id: str | None = None
|
|
279
|
+
try:
|
|
280
|
+
request = _load_json(text)
|
|
281
|
+
request_id = str(request.get("id") or "").strip() or None
|
|
282
|
+
if request.get("op") == "shutdown":
|
|
283
|
+
print(json.dumps({"id": request_id, "ok": True, "stopped": True}), flush=True)
|
|
284
|
+
break
|
|
285
|
+
payload = await _run_read_worker_tool(context, request)
|
|
286
|
+
print(
|
|
287
|
+
json.dumps(
|
|
288
|
+
{
|
|
289
|
+
"id": request_id,
|
|
290
|
+
"ok": True,
|
|
291
|
+
"payload": payload,
|
|
292
|
+
}
|
|
293
|
+
),
|
|
294
|
+
flush=True,
|
|
295
|
+
)
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
print(
|
|
298
|
+
json.dumps(
|
|
299
|
+
{
|
|
300
|
+
"id": request_id,
|
|
301
|
+
**_error_payload_for_exception(exc),
|
|
302
|
+
}
|
|
303
|
+
),
|
|
304
|
+
flush=True,
|
|
305
|
+
)
|
|
306
|
+
return 0
|
|
307
|
+
|
|
308
|
+
|
|
253
309
|
async def _run_btc_wallet_get(user_id: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
254
310
|
from agent_wallet.btc_user_wallets import get_user_btc_wallet_binding
|
|
255
311
|
|
|
@@ -522,6 +578,19 @@ def main() -> int:
|
|
|
522
578
|
evm_lock_parser.add_argument("--user-id", required=True)
|
|
523
579
|
evm_lock_parser.add_argument("--config-json", default="{}")
|
|
524
580
|
|
|
581
|
+
read_worker_parser = subparsers.add_parser("read-worker")
|
|
582
|
+
read_worker_parser.add_argument("--user-id", required=True)
|
|
583
|
+
read_worker_parser.add_argument("--config-json", default="{}")
|
|
584
|
+
|
|
585
|
+
subparsers.add_parser("boot-key-export")
|
|
586
|
+
|
|
587
|
+
boot_key_import_parser = subparsers.add_parser("boot-key-import")
|
|
588
|
+
boot_key_import_parser.add_argument(
|
|
589
|
+
"--key-stdin",
|
|
590
|
+
action="store_true",
|
|
591
|
+
help="Read the boot key to import from stdin.",
|
|
592
|
+
)
|
|
593
|
+
|
|
525
594
|
args = parser.parse_args()
|
|
526
595
|
|
|
527
596
|
try:
|
|
@@ -643,6 +712,19 @@ def main() -> int:
|
|
|
643
712
|
)
|
|
644
713
|
elif args.command == "evm-wallet-lock":
|
|
645
714
|
payload = asyncio.run(_run_evm_wallet_lock(args.user_id, config))
|
|
715
|
+
elif args.command == "read-worker":
|
|
716
|
+
return asyncio.run(_serve_read_worker(args.user_id, config))
|
|
717
|
+
elif args.command == "boot-key-export":
|
|
718
|
+
from agent_wallet.boot_key_recovery import export_boot_key, export_warning
|
|
719
|
+
|
|
720
|
+
print(export_warning(), file=sys.stderr)
|
|
721
|
+
payload = {"boot_key": export_boot_key()}
|
|
722
|
+
elif args.command == "boot-key-import":
|
|
723
|
+
if not args.key_stdin:
|
|
724
|
+
raise WalletBackendError("boot-key-import requires --key-stdin.")
|
|
725
|
+
from agent_wallet.boot_key_recovery import import_boot_key
|
|
726
|
+
|
|
727
|
+
payload = import_boot_key(_read_stdin_secret("boot key"))
|
|
646
728
|
else:
|
|
647
729
|
payload = asyncio.run(
|
|
648
730
|
_run_invoke(
|
|
@@ -662,13 +744,7 @@ def main() -> int:
|
|
|
662
744
|
backend=str(locals().get("config", {}).get("backend", "") or ""),
|
|
663
745
|
ok=False,
|
|
664
746
|
)
|
|
665
|
-
|
|
666
|
-
if isinstance(exc, WalletBackendError):
|
|
667
|
-
if exc.code:
|
|
668
|
-
error_payload["code"] = exc.code
|
|
669
|
-
if exc.details is not None:
|
|
670
|
-
error_payload["details"] = exc.details
|
|
671
|
-
print(json.dumps(error_payload), file=sys.stderr)
|
|
747
|
+
print(json.dumps(_error_payload_for_exception(exc)), file=sys.stderr)
|
|
672
748
|
return 1
|
|
673
749
|
|
|
674
750
|
if getattr(args, "command", "") == "invoke":
|
|
@@ -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:
|
|
@@ -87,6 +106,7 @@ def onboard_openclaw_user_wallet(
|
|
|
87
106
|
*,
|
|
88
107
|
backend: str | None = None,
|
|
89
108
|
sign_only: bool | None = None,
|
|
109
|
+
read_only: bool = False,
|
|
90
110
|
network: str | None = None,
|
|
91
111
|
rpc_url: str | None = None,
|
|
92
112
|
wdk_btc_service_url: str | None = None,
|
|
@@ -97,7 +117,11 @@ def onboard_openclaw_user_wallet(
|
|
|
97
117
|
wdk_evm_account_index: int | None = None,
|
|
98
118
|
) -> OpenClawWalletRuntimeContext:
|
|
99
119
|
"""Provision and assemble a runtime-ready wallet context for one OpenClaw user."""
|
|
120
|
+
ensure_boot_key_migrated_once()
|
|
100
121
|
backend_name = str(backend or settings.agent_wallet_backend).strip().lower()
|
|
122
|
+
effective_sign_only = True if read_only else (
|
|
123
|
+
settings.agent_wallet_sign_only if sign_only is None else sign_only
|
|
124
|
+
)
|
|
101
125
|
if backend_name in {"wdk_btc_local", "wdk-btc-local", "btc_local", "btc-local"}:
|
|
102
126
|
service_url = str(wdk_btc_service_url or settings.wdk_btc_service_url).strip()
|
|
103
127
|
account_index = (
|
|
@@ -133,7 +157,7 @@ def onboard_openclaw_user_wallet(
|
|
|
133
157
|
wallet_id=wallet_id,
|
|
134
158
|
network=effective_network,
|
|
135
159
|
account_index=account_index,
|
|
136
|
-
sign_only=
|
|
160
|
+
sign_only=effective_sign_only,
|
|
137
161
|
address=str(address_payload.get("address") or "").strip() or None,
|
|
138
162
|
)
|
|
139
163
|
wallet_info = {
|
|
@@ -188,7 +212,7 @@ def onboard_openclaw_user_wallet(
|
|
|
188
212
|
wallet_id=wallet_id,
|
|
189
213
|
network=effective_network,
|
|
190
214
|
account_index=account_index,
|
|
191
|
-
sign_only=
|
|
215
|
+
sign_only=effective_sign_only,
|
|
192
216
|
address=resolved_address or None,
|
|
193
217
|
)
|
|
194
218
|
wallet_info = {
|
|
@@ -213,7 +237,8 @@ def onboard_openclaw_user_wallet(
|
|
|
213
237
|
|
|
214
238
|
backend, wallet_info, created_now = create_openclaw_solana_backend(
|
|
215
239
|
user_id,
|
|
216
|
-
sign_only=
|
|
240
|
+
sign_only=effective_sign_only,
|
|
241
|
+
read_only=read_only,
|
|
217
242
|
network=network,
|
|
218
243
|
rpc_url=rpc_url,
|
|
219
244
|
)
|
|
@@ -483,3 +483,37 @@ async def fetch_prices(
|
|
|
483
483
|
if not isinstance(data, dict):
|
|
484
484
|
raise ProviderError("jupiter", "Unexpected price response from Jupiter.")
|
|
485
485
|
return data
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
async def fetch_token_metadata(*, mints: list[str]) -> dict[str, dict[str, Any]]:
|
|
489
|
+
"""Look up token symbol/name for a batch of mints via Jupiter's token
|
|
490
|
+
search API. Keyed by mint address; mints Jupiter doesn't index (very new
|
|
491
|
+
or illiquid tokens) are simply absent from the result rather than
|
|
492
|
+
raising, since this is display-only enrichment on top of the price data.
|
|
493
|
+
"""
|
|
494
|
+
if not mints:
|
|
495
|
+
return {}
|
|
496
|
+
client = get_client()
|
|
497
|
+
params = {"query": ",".join(mints)}
|
|
498
|
+
response = await client.get(
|
|
499
|
+
f"{settings.jupiter_token_search_api_base_url.rstrip('/')}/search",
|
|
500
|
+
params=params,
|
|
501
|
+
headers=_headers(),
|
|
502
|
+
)
|
|
503
|
+
if response.status_code != 200:
|
|
504
|
+
raise ProviderError("jupiter", f"HTTP {response.status_code}: {response.text[:300]}")
|
|
505
|
+
data = response.json()
|
|
506
|
+
if not isinstance(data, list):
|
|
507
|
+
raise ProviderError("jupiter", "Unexpected token search response from Jupiter.")
|
|
508
|
+
result: dict[str, dict[str, Any]] = {}
|
|
509
|
+
for item in data:
|
|
510
|
+
if not isinstance(item, dict):
|
|
511
|
+
continue
|
|
512
|
+
mint = str(item.get("id") or "").strip()
|
|
513
|
+
if not mint:
|
|
514
|
+
continue
|
|
515
|
+
result[mint] = {
|
|
516
|
+
"symbol": item.get("symbol"),
|
|
517
|
+
"name": item.get("name"),
|
|
518
|
+
}
|
|
519
|
+
return result
|