@agentlayer.tech/wallet 0.1.91 → 0.1.92
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 +21 -0
- package/README.md +86 -490
- package/VERSION +1 -1
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/boot_key_migration.py +10 -21
- package/agent-wallet/agent_wallet/config.py +20 -0
- package/agent-wallet/agent_wallet/evm_user_wallets.py +176 -25
- package/agent-wallet/agent_wallet/keystore.py +112 -17
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +1 -1
- package/bin/lib/evm-daemon.mjs +375 -0
- package/bin/openclaw-agent-wallet.mjs +7 -0
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
- package/package.json +2 -2
- package/wdk-btc-wallet/package.json +1 -1
- package/wdk-evm-wallet/package.json +3 -2
- package/wdk-evm-wallet/src/server.js +24 -3
- package/wdk-evm-wallet/src/shutdown.js +63 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Best-effort stop of the wdk-evm-wallet daemon that belongs to the wallet
|
|
2
|
+
// home being updated. The installer never trusts /health alone: the reported
|
|
3
|
+
// PID must also own the local listening socket and run from a wdk-evm-wallet
|
|
4
|
+
// working directory. Every failure is advisory and leaves the process alone.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import http from "node:http";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_SERVICE_URL = "http://127.0.0.1:8081";
|
|
13
|
+
const STOP_TIMEOUT_MS = 10000;
|
|
14
|
+
const KILL_TIMEOUT_MS = 5000;
|
|
15
|
+
const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
16
|
+
|
|
17
|
+
function healthUrl(serviceUrl) {
|
|
18
|
+
return `${String(serviceUrl).replace(/\/+$/, "")}/health`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function expandHome(value, env = process.env) {
|
|
22
|
+
const raw = String(value || "").trim();
|
|
23
|
+
const home = String(env.HOME || os.homedir()).trim() || os.homedir();
|
|
24
|
+
if (raw === "~") return home;
|
|
25
|
+
if (raw.startsWith("~/")) return path.join(home, raw.slice(2));
|
|
26
|
+
return raw;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function daemonTakeoverDisabled(env = process.env) {
|
|
30
|
+
return ["1", "true", "yes", "on"].includes(
|
|
31
|
+
String(env.OPENCLAW_EVM_DISABLE_DAEMON_TAKEOVER || "").trim().toLowerCase(),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isLoopbackServiceUrl(serviceUrl) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(serviceUrl);
|
|
38
|
+
return parsed.protocol === "http:" && LOCAL_HOSTS.has(parsed.hostname);
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function expectedDataDirFor(env = process.env) {
|
|
45
|
+
const configured = String(env.WDK_EVM_DATA_DIR || "").trim();
|
|
46
|
+
if (configured) return path.resolve(expandHome(configured, env));
|
|
47
|
+
const home = expandHome(env.OPENCLAW_HOME || path.join(env.HOME || os.homedir(), ".openclaw"), env);
|
|
48
|
+
return path.resolve(home, "wdk-evm-wallet");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function samePath(left, right) {
|
|
52
|
+
if (!left || !right) return false;
|
|
53
|
+
try {
|
|
54
|
+
return path.resolve(expandHome(left)) === path.resolve(expandHome(right));
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readJsonFile(pathname) {
|
|
61
|
+
try {
|
|
62
|
+
return { present: true, valid: true, value: JSON.parse(fs.readFileSync(pathname, "utf8")) };
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error?.code === "ENOENT") return { present: false, valid: true, value: null };
|
|
65
|
+
return { present: true, valid: false, value: null };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readServiceOwner(dataDir) {
|
|
70
|
+
return readJsonFile(path.join(dataDir, "service-owner.json"));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function runLsof(args, env = process.env) {
|
|
74
|
+
const result = spawnSync("lsof", args, {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
timeout: 5000,
|
|
77
|
+
env,
|
|
78
|
+
});
|
|
79
|
+
if (result.error) return null;
|
|
80
|
+
// lsof uses status 1 when no rows match. That is a valid empty result.
|
|
81
|
+
if (![0, 1].includes(result.status)) return null;
|
|
82
|
+
return String(result.stdout || "");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parsePids(raw) {
|
|
86
|
+
return [...new Set(
|
|
87
|
+
String(raw || "")
|
|
88
|
+
.split(/\s+/)
|
|
89
|
+
.filter((token) => /^\d+$/.test(token))
|
|
90
|
+
.map((token) => Number(token))
|
|
91
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0),
|
|
92
|
+
)];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function inspectDaemonProcess(pid, port, env = process.env) {
|
|
96
|
+
const listenerOutput = runLsof(
|
|
97
|
+
["-nP", "-t", "-iTCP:" + String(port), "-sTCP:LISTEN"],
|
|
98
|
+
env,
|
|
99
|
+
);
|
|
100
|
+
const cwdOutput = runLsof(["-a", "-p", String(pid), "-d", "cwd", "-Fn"], env);
|
|
101
|
+
if (listenerOutput === null || cwdOutput === null) {
|
|
102
|
+
return { available: false, listenerPids: [], cwd: "" };
|
|
103
|
+
}
|
|
104
|
+
const cwd = cwdOutput
|
|
105
|
+
.split(/\r?\n/)
|
|
106
|
+
.find((line) => line.startsWith("n"))
|
|
107
|
+
?.slice(1)
|
|
108
|
+
.trim() || "";
|
|
109
|
+
return {
|
|
110
|
+
available: true,
|
|
111
|
+
listenerPids: parsePids(listenerOutput),
|
|
112
|
+
cwd,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function cwdLooksLikeEvmDaemon(cwd) {
|
|
117
|
+
if (!cwd) return false;
|
|
118
|
+
try {
|
|
119
|
+
return path.basename(path.resolve(cwd)) === "wdk-evm-wallet";
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ownerMatches({ ownerState, health, pid, port, expectedDataDir }) {
|
|
126
|
+
if (!ownerState.valid) return false;
|
|
127
|
+
if (!ownerState.present) return true;
|
|
128
|
+
const owner = ownerState.value;
|
|
129
|
+
if (!owner || typeof owner !== "object") return false;
|
|
130
|
+
return (
|
|
131
|
+
Number(owner.pid) === pid &&
|
|
132
|
+
Number(owner.port) === port &&
|
|
133
|
+
samePath(owner.data_dir, expectedDataDir) &&
|
|
134
|
+
String(owner.instance_id || "") === String(health.instanceId || "")
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function classifyDaemonHealth(
|
|
139
|
+
health,
|
|
140
|
+
{
|
|
141
|
+
expectedDataDir,
|
|
142
|
+
port,
|
|
143
|
+
inspection = { available: false, listenerPids: [], cwd: "" },
|
|
144
|
+
ownerState = { present: false, valid: true, value: null },
|
|
145
|
+
} = {},
|
|
146
|
+
) {
|
|
147
|
+
if (!health || typeof health !== "object") {
|
|
148
|
+
return { stoppable: false, reason: "not_running", pid: 0 };
|
|
149
|
+
}
|
|
150
|
+
if (health.service !== "wdk-evm-wallet") {
|
|
151
|
+
return { stoppable: false, reason: "foreign_service", pid: 0 };
|
|
152
|
+
}
|
|
153
|
+
const reportedDataDir = String(health.dataDir || "").trim();
|
|
154
|
+
if (!reportedDataDir || !samePath(reportedDataDir, expectedDataDir)) {
|
|
155
|
+
return { stoppable: false, reason: "foreign_vault", pid: 0 };
|
|
156
|
+
}
|
|
157
|
+
const pid = typeof health.pid === "number" ? health.pid : Number.NaN;
|
|
158
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
159
|
+
return { stoppable: false, reason: "no_pid", pid: 0 };
|
|
160
|
+
}
|
|
161
|
+
if (!inspection.available) {
|
|
162
|
+
return { stoppable: false, reason: "process_inspection_unavailable", pid };
|
|
163
|
+
}
|
|
164
|
+
if (!inspection.listenerPids.includes(pid)) {
|
|
165
|
+
return { stoppable: false, reason: "pid_not_listener", pid };
|
|
166
|
+
}
|
|
167
|
+
if (!cwdLooksLikeEvmDaemon(inspection.cwd)) {
|
|
168
|
+
return { stoppable: false, reason: "foreign_process", pid };
|
|
169
|
+
}
|
|
170
|
+
if (!ownerMatches({ ownerState, health, pid, port, expectedDataDir })) {
|
|
171
|
+
return { stoppable: false, reason: "owner_mismatch", pid };
|
|
172
|
+
}
|
|
173
|
+
return { stoppable: true, reason: "stoppable", pid };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function readDaemonHealth(serviceUrl, timeoutMs = 1500) {
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
let settled = false;
|
|
179
|
+
const done = (value) => {
|
|
180
|
+
if (!settled) {
|
|
181
|
+
settled = true;
|
|
182
|
+
resolve(value);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const request = http.get(healthUrl(serviceUrl), { timeout: timeoutMs }, (response) => {
|
|
186
|
+
if (response.statusCode !== 200) {
|
|
187
|
+
response.resume();
|
|
188
|
+
done(null);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let raw = "";
|
|
192
|
+
response.setEncoding("utf8");
|
|
193
|
+
response.on("data", (chunk) => {
|
|
194
|
+
raw += chunk;
|
|
195
|
+
});
|
|
196
|
+
response.on("end", () => {
|
|
197
|
+
try {
|
|
198
|
+
done(JSON.parse(raw));
|
|
199
|
+
} catch {
|
|
200
|
+
done(null);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
request.on("timeout", () => {
|
|
205
|
+
request.destroy();
|
|
206
|
+
done(null);
|
|
207
|
+
});
|
|
208
|
+
request.on("error", () => done(null));
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function processExists(pid) {
|
|
213
|
+
try {
|
|
214
|
+
process.kill(pid, 0);
|
|
215
|
+
return true;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return error?.code !== "ESRCH";
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function processIsSignalable(pid) {
|
|
222
|
+
try {
|
|
223
|
+
process.kill(pid, 0);
|
|
224
|
+
return true;
|
|
225
|
+
} catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function processStillMatches(pid, port, expectedDataDir, ownerState, env) {
|
|
231
|
+
const inspection = inspectDaemonProcess(pid, port, env);
|
|
232
|
+
if (
|
|
233
|
+
!inspection.available ||
|
|
234
|
+
!inspection.listenerPids.includes(pid) ||
|
|
235
|
+
!cwdLooksLikeEvmDaemon(inspection.cwd)
|
|
236
|
+
) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
if (!ownerState.valid || !ownerState.present) return false;
|
|
240
|
+
const owner = ownerState.value;
|
|
241
|
+
return (
|
|
242
|
+
Number(owner?.pid) === pid &&
|
|
243
|
+
Number(owner?.port) === port &&
|
|
244
|
+
samePath(owner?.data_dir, expectedDataDir)
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
249
|
+
|
|
250
|
+
async function waitForExit(pid, timeoutMs) {
|
|
251
|
+
const deadline = Date.now() + timeoutMs;
|
|
252
|
+
while (Date.now() < deadline) {
|
|
253
|
+
if (!processExists(pid)) return true;
|
|
254
|
+
await sleep(300);
|
|
255
|
+
}
|
|
256
|
+
return !processExists(pid);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function stopLocalEvmDaemon({ serviceUrl, env = process.env } = {}) {
|
|
260
|
+
if (daemonTakeoverDisabled(env)) {
|
|
261
|
+
return { attempted: false, stopped: false, reason: "takeover_disabled", pid: 0 };
|
|
262
|
+
}
|
|
263
|
+
const url =
|
|
264
|
+
String(env.WDK_EVM_SERVICE_URL || serviceUrl || DEFAULT_SERVICE_URL).trim() ||
|
|
265
|
+
DEFAULT_SERVICE_URL;
|
|
266
|
+
if (!isLoopbackServiceUrl(url)) {
|
|
267
|
+
return { attempted: false, stopped: false, reason: "non_local_service_url", pid: 0 };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const parsed = new URL(url);
|
|
271
|
+
const port = Number(parsed.port || 80);
|
|
272
|
+
const expectedDataDir = expectedDataDirFor(env);
|
|
273
|
+
const health = await readDaemonHealth(url);
|
|
274
|
+
const reportedPid = Number(health?.pid || 0);
|
|
275
|
+
const inspection =
|
|
276
|
+
Number.isInteger(reportedPid) && reportedPid > 0
|
|
277
|
+
? inspectDaemonProcess(reportedPid, port, env)
|
|
278
|
+
: { available: false, listenerPids: [], cwd: "" };
|
|
279
|
+
const ownerState = readServiceOwner(expectedDataDir);
|
|
280
|
+
const verdict = classifyDaemonHealth(health, {
|
|
281
|
+
expectedDataDir,
|
|
282
|
+
port,
|
|
283
|
+
inspection,
|
|
284
|
+
ownerState,
|
|
285
|
+
});
|
|
286
|
+
if (!verdict.stoppable) {
|
|
287
|
+
return { attempted: false, stopped: false, reason: verdict.reason, pid: verdict.pid };
|
|
288
|
+
}
|
|
289
|
+
if (!processIsSignalable(verdict.pid)) {
|
|
290
|
+
return { attempted: false, stopped: false, reason: "pid_not_signalable", pid: verdict.pid };
|
|
291
|
+
}
|
|
292
|
+
// Close the remaining PID-reuse window as much as portable macOS/Linux APIs
|
|
293
|
+
// allow by rechecking the listener immediately before the signal.
|
|
294
|
+
const finalInspection = inspectDaemonProcess(verdict.pid, port, env);
|
|
295
|
+
if (
|
|
296
|
+
!finalInspection.available ||
|
|
297
|
+
!finalInspection.listenerPids.includes(verdict.pid) ||
|
|
298
|
+
!cwdLooksLikeEvmDaemon(finalInspection.cwd)
|
|
299
|
+
) {
|
|
300
|
+
return { attempted: false, stopped: false, reason: "identity_changed", pid: verdict.pid };
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
process.kill(verdict.pid, "SIGTERM");
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return {
|
|
306
|
+
attempted: true,
|
|
307
|
+
stopped: false,
|
|
308
|
+
reason: `signal_failed:${error?.code || "unknown"}`,
|
|
309
|
+
pid: verdict.pid,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (await waitForExit(verdict.pid, STOP_TIMEOUT_MS)) {
|
|
313
|
+
return { attempted: true, stopped: true, reason: "stopped", pid: verdict.pid };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// A hard stop is only allowed when the exact same owned process still owns
|
|
317
|
+
// the socket. Missing owner evidence or any identity change fails closed.
|
|
318
|
+
if (!processStillMatches(verdict.pid, port, expectedDataDir, ownerState, env)) {
|
|
319
|
+
return { attempted: true, stopped: false, reason: "still_running_unverified", pid: verdict.pid };
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
process.kill(verdict.pid, "SIGKILL");
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error?.code !== "ESRCH") {
|
|
325
|
+
return {
|
|
326
|
+
attempted: true,
|
|
327
|
+
stopped: false,
|
|
328
|
+
reason: `kill_failed:${error?.code || "unknown"}`,
|
|
329
|
+
pid: verdict.pid,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const stopped = await waitForExit(verdict.pid, KILL_TIMEOUT_MS);
|
|
334
|
+
return {
|
|
335
|
+
attempted: true,
|
|
336
|
+
stopped,
|
|
337
|
+
reason: stopped ? "killed" : "still_running",
|
|
338
|
+
pid: verdict.pid,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// The install and rollback paths are synchronous. Run the bounded async stop
|
|
343
|
+
// worker in a short-lived subprocess rather than making the CLI lifecycle async.
|
|
344
|
+
export function stopLocalEvmDaemonSync({ env = process.env } = {}) {
|
|
345
|
+
const fallback = { attempted: false, stopped: false, reason: "subprocess_failed", pid: 0 };
|
|
346
|
+
try {
|
|
347
|
+
const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), "--stop"], {
|
|
348
|
+
encoding: "utf8",
|
|
349
|
+
timeout: STOP_TIMEOUT_MS + KILL_TIMEOUT_MS + 5000,
|
|
350
|
+
env,
|
|
351
|
+
});
|
|
352
|
+
if (result.status !== 0 || !result.stdout) return fallback;
|
|
353
|
+
return JSON.parse(result.stdout.trim());
|
|
354
|
+
} catch {
|
|
355
|
+
return fallback;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (
|
|
360
|
+
process.argv[1] &&
|
|
361
|
+
fileURLToPath(import.meta.url) === process.argv[1] &&
|
|
362
|
+
process.argv.includes("--stop")
|
|
363
|
+
) {
|
|
364
|
+
stopLocalEvmDaemon()
|
|
365
|
+
.then((result) => {
|
|
366
|
+
process.stdout.write(JSON.stringify(result));
|
|
367
|
+
process.exit(0);
|
|
368
|
+
})
|
|
369
|
+
.catch(() => {
|
|
370
|
+
process.stdout.write(
|
|
371
|
+
JSON.stringify({ attempted: false, stopped: false, reason: "worker_failed", pid: 0 }),
|
|
372
|
+
);
|
|
373
|
+
process.exit(0);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
detectHosts,
|
|
14
14
|
stripUniversalInstallerArgs,
|
|
15
15
|
} from "./lib/host-detection.mjs";
|
|
16
|
+
import { stopLocalEvmDaemonSync } from "./lib/evm-daemon.mjs";
|
|
16
17
|
import { createHostIntegrationManager, createIntegrationManager } from "./lib/integrations.mjs";
|
|
17
18
|
import { createUpdateTransactionManager } from "./lib/update-transaction.mjs";
|
|
18
19
|
|
|
@@ -1997,6 +1998,9 @@ function runInstallUnlocked(args, { commandName = "install", installPlan = null
|
|
|
1997
1998
|
{ ...readUpdateJournal(env), release_root: releaseRoot, previous_runtime: previousTarget },
|
|
1998
1999
|
env,
|
|
1999
2000
|
);
|
|
2001
|
+
// Daemon restart is advisory lifecycle cleanup, not part of the atomic
|
|
2002
|
+
// runtime commit. Record the successful update before waiting on it.
|
|
2003
|
+
const evmDaemonStop = stopLocalEvmDaemonSync({ env });
|
|
2000
2004
|
|
|
2001
2005
|
const integrationRegistryRecovery = integrations(env).recoverCorruptRegistry();
|
|
2002
2006
|
const hostInstallation = applyHostInstallPlan(hostPlan, args, env);
|
|
@@ -2033,6 +2037,7 @@ function runInstallUnlocked(args, { commandName = "install", installPlan = null
|
|
|
2033
2037
|
integration_registry_recovery: integrationRegistryRecovery,
|
|
2034
2038
|
integration_refresh: hostInstallation.refreshed,
|
|
2035
2039
|
global_cli_refresh: globalCliRefresh,
|
|
2040
|
+
evm_daemon_stop: evmDaemonStop,
|
|
2036
2041
|
...(hostInstallFailed
|
|
2037
2042
|
? {
|
|
2038
2043
|
category: "host_install_failed",
|
|
@@ -2268,12 +2273,14 @@ function runRollback(args) {
|
|
|
2268
2273
|
switchSymlink(previousRuntimePath(), releaseRootFor(current));
|
|
2269
2274
|
}
|
|
2270
2275
|
switchSymlink(currentPath, target);
|
|
2276
|
+
const evmDaemonStop = stopLocalEvmDaemonSync();
|
|
2271
2277
|
console.log(
|
|
2272
2278
|
JSON.stringify(
|
|
2273
2279
|
{
|
|
2274
2280
|
ok: true,
|
|
2275
2281
|
active_version: activeVersion(),
|
|
2276
2282
|
current_runtime: currentPath,
|
|
2283
|
+
evm_daemon_stop: evmDaemonStop,
|
|
2277
2284
|
},
|
|
2278
2285
|
null,
|
|
2279
2286
|
2,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-wallet",
|
|
3
3
|
"displayName": "Agent Wallet",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.92",
|
|
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"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentlayer.tech/wallet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.92",
|
|
4
4
|
"description": "Universal AgentLayer wallet installer for OpenClaw, Codex, Claude Code, and Hermes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"wallet": "./bin/openclaw-agent-wallet.mjs"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
-
"check": "node --check bin/openclaw-agent-wallet.mjs && node --test bin/lib/host-detection.test.mjs",
|
|
18
|
+
"check": "node --check bin/openclaw-agent-wallet.mjs && node --check bin/lib/evm-daemon.mjs && node --test bin/lib/host-detection.test.mjs bin/lib/evm-daemon.test.mjs",
|
|
19
19
|
"build:openclaw-plugins": "node scripts/manage_openclaw_plugin_packages.mjs build",
|
|
20
20
|
"check:openclaw-plugins": "node scripts/manage_openclaw_plugin_packages.mjs check",
|
|
21
21
|
"check:release-version": "node scripts/check_release_version.mjs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wdk-evm-wallet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.92",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Separate EVM wallet service built on Tether WDK.",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"bootstrap": "sh ./bootstrap.sh",
|
|
9
9
|
"start:local": "sh ./run-local.sh",
|
|
10
10
|
"start": "node src/server.js",
|
|
11
|
-
"check": "node --check src/server.js && node --check src/wdk_evm_wallet.js && node --check src/config.js && node --check src/json.js && node --check src/local_vault.js && node --check src/network_state.js",
|
|
11
|
+
"check": "node --check src/server.js && node --check src/shutdown.js && node --check src/wdk_evm_wallet.js && node --check src/config.js && node --check src/json.js && node --check src/local_vault.js && node --check src/network_state.js",
|
|
12
12
|
"test:swap-runtime": "node --test --test-concurrency=1 tests/smoke_swap_runtime.mjs",
|
|
13
13
|
"test:aave-runtime": "node --test --test-concurrency=1 tests/smoke_aave_runtime.mjs",
|
|
14
14
|
"test:morpho-runtime": "node --test --test-concurrency=1 tests/smoke_morpho_runtime.mjs",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"test:uniswap-runtime": "node --test --test-concurrency=1 tests/smoke_uniswap_runtime.mjs",
|
|
17
17
|
"test:unit": "node --test tests/unit_uniswap_helpers.mjs",
|
|
18
18
|
"test:identity": "node --test tests/unit_instance_identity.mjs",
|
|
19
|
+
"test:shutdown": "node --test tests/shutdown.test.mjs",
|
|
19
20
|
"test:network-config": "node --test tests/unit_network_config.mjs",
|
|
20
21
|
"test:network-state": "node --test tests/unit_network_state.mjs",
|
|
21
22
|
"test:wallet-network": "node --test tests/unit_wdk_wallet_network.mjs"
|
|
@@ -7,6 +7,7 @@ import { loadConfig } from "./config.js";
|
|
|
7
7
|
import { readJsonBody, sendJson } from "./json.js";
|
|
8
8
|
import { LocalEvmVault } from "./local_vault.js";
|
|
9
9
|
import { EvmNetworkState } from "./network_state.js";
|
|
10
|
+
import { createShutdownCoordinator, withTrackedRequest } from "./shutdown.js";
|
|
10
11
|
import { WdkEvmWalletService } from "./wdk_evm_wallet.js";
|
|
11
12
|
|
|
12
13
|
const config = loadConfig();
|
|
@@ -683,12 +684,32 @@ async function handleRequest(request, response) {
|
|
|
683
684
|
}
|
|
684
685
|
|
|
685
686
|
const server = createServer((request, response) => {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
687
|
+
void withTrackedRequest(shutdown, async () => {
|
|
688
|
+
try {
|
|
689
|
+
await handleRequest(request, response);
|
|
690
|
+
} catch (error) {
|
|
691
|
+
const shaped = toErrorResponse(
|
|
692
|
+
error,
|
|
693
|
+
new URL(request.url || "/", "http://localhost").pathname,
|
|
694
|
+
500,
|
|
695
|
+
);
|
|
696
|
+
sendJson(response, shaped.statusCode, shaped.payload);
|
|
697
|
+
}
|
|
689
698
|
});
|
|
690
699
|
});
|
|
691
700
|
|
|
701
|
+
// 8s stays strictly inside the 10s SIGTERM->SIGKILL window the Python client
|
|
702
|
+
// allows (agent_wallet/evm_user_wallets.py), so the orderly path always wins.
|
|
703
|
+
const shutdown = createShutdownCoordinator({
|
|
704
|
+
closeServer: () => server.close(),
|
|
705
|
+
exit: (code) => process.exit(code),
|
|
706
|
+
graceMs: 8000,
|
|
707
|
+
log: (message) => console.log(message),
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
process.on("SIGTERM", () => shutdown.begin("SIGTERM"));
|
|
711
|
+
process.on("SIGINT", () => shutdown.begin("SIGINT"));
|
|
712
|
+
|
|
692
713
|
server.listen(config.port, config.host, () => {
|
|
693
714
|
console.log(
|
|
694
715
|
`wdk-evm-wallet listening on ${config.host}:${config.port} (${config.network})`
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Coordinates an orderly shutdown: stop accepting connections, let in-flight
|
|
2
|
+
// requests finish, then exit. Vault writes are not atomic (see local_vault.js),
|
|
3
|
+
// so an abrupt exit mid-write can truncate a wallet file. Every dependency is
|
|
4
|
+
// injectable so the drain loop can be tested without real timers.
|
|
5
|
+
export function createShutdownCoordinator({
|
|
6
|
+
closeServer,
|
|
7
|
+
exit,
|
|
8
|
+
now = () => Date.now(),
|
|
9
|
+
schedule = (fn, ms) => setTimeout(fn, ms),
|
|
10
|
+
graceMs = 8000,
|
|
11
|
+
pollMs = 100,
|
|
12
|
+
log = () => {},
|
|
13
|
+
}) {
|
|
14
|
+
let inFlight = 0;
|
|
15
|
+
let shuttingDown = false;
|
|
16
|
+
|
|
17
|
+
function begin(signalName) {
|
|
18
|
+
if (shuttingDown) return;
|
|
19
|
+
shuttingDown = true;
|
|
20
|
+
log(
|
|
21
|
+
`wdk-evm-wallet received ${signalName}, draining ${inFlight} in-flight request(s)`,
|
|
22
|
+
);
|
|
23
|
+
closeServer();
|
|
24
|
+
|
|
25
|
+
const deadline = now() + graceMs;
|
|
26
|
+
const tick = () => {
|
|
27
|
+
if (inFlight <= 0 || now() >= deadline) {
|
|
28
|
+
exit(0);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
schedule(tick, pollMs);
|
|
32
|
+
};
|
|
33
|
+
tick();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
begin,
|
|
38
|
+
trackStart() {
|
|
39
|
+
inFlight += 1;
|
|
40
|
+
},
|
|
41
|
+
trackEnd() {
|
|
42
|
+
if (inFlight > 0) inFlight -= 1;
|
|
43
|
+
},
|
|
44
|
+
isShuttingDown() {
|
|
45
|
+
return shuttingDown;
|
|
46
|
+
},
|
|
47
|
+
get inFlight() {
|
|
48
|
+
return inFlight;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Track the lifetime of the handler itself, not the HTTP connection. A client
|
|
54
|
+
// can disconnect while a vault write or transaction is still running, and the
|
|
55
|
+
// response "close" event must not make shutdown treat that work as finished.
|
|
56
|
+
export async function withTrackedRequest(coordinator, handler) {
|
|
57
|
+
coordinator.trackStart();
|
|
58
|
+
try {
|
|
59
|
+
return await handler();
|
|
60
|
+
} finally {
|
|
61
|
+
coordinator.trackEnd();
|
|
62
|
+
}
|
|
63
|
+
}
|