@dominusnode/openclaw-plugin 1.0.0

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/dist/plugin.js ADDED
@@ -0,0 +1,1641 @@
1
+ /**
2
+ * DomiNode OpenClaw Plugin
3
+ *
4
+ * Implements 24 tools for interacting with DomiNode's rotating proxy service
5
+ * directly from OpenClaw AI coding sessions.
6
+ *
7
+ * Uses native fetch (no external dependencies). Runs via jiti runtime.
8
+ *
9
+ * Security:
10
+ * - Full SSRF protection (private IP blocking, DNS rebinding, .localhost, embedded creds)
11
+ * - Credential scrubbing in error messages
12
+ * - Response truncation at 4000 chars for LLM context efficiency
13
+ * - OFAC sanctioned country validation
14
+ */
15
+ import * as http from "node:http";
16
+ import * as tls from "node:tls";
17
+ import * as dns from "dns/promises";
18
+ // ---------------------------------------------------------------------------
19
+ // Configuration
20
+ // ---------------------------------------------------------------------------
21
+ const MAX_RESPONSE_CHARS = 4000;
22
+ const REQUEST_TIMEOUT_MS = 30_000;
23
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB hard cap on fetch responses
24
+ /** OFAC sanctioned countries — must never be used as geo-targeting destinations. */
25
+ const SANCTIONED_COUNTRIES = new Set(["CU", "IR", "KP", "RU", "SY"]);
26
+ /** ISO 3166-1 alpha-2 country code pattern. */
27
+ const COUNTRY_CODE_RE = /^[A-Z]{2}$/;
28
+ /** UUID v4 pattern for wallet/team IDs. */
29
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
30
+ /** Valid domain name pattern (e.g. example.com, sub.example.co.uk). */
31
+ const DOMAIN_RE = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
32
+ // ---------------------------------------------------------------------------
33
+ // Environment helpers
34
+ // ---------------------------------------------------------------------------
35
+ function getApiKey() {
36
+ const key = process.env.DOMINUSNODE_API_KEY;
37
+ if (!key || typeof key !== "string" || key.trim().length === 0) {
38
+ throw new Error("DOMINUSNODE_API_KEY environment variable is required. " +
39
+ 'Set it to your DomiNode API key (starts with "dn_live_" or "dn_test_").');
40
+ }
41
+ const trimmed = key.trim();
42
+ if (!trimmed.startsWith("dn_live_") && !trimmed.startsWith("dn_test_")) {
43
+ throw new Error('DOMINUSNODE_API_KEY must start with "dn_live_" or "dn_test_".');
44
+ }
45
+ return trimmed;
46
+ }
47
+ function getBaseUrl() {
48
+ const url = process.env.DOMINUSNODE_BASE_URL;
49
+ if (url && typeof url === "string" && url.trim().length > 0) {
50
+ // Strip trailing slash
51
+ return url.trim().replace(/\/+$/, "");
52
+ }
53
+ return "https://api.dominusnode.com";
54
+ }
55
+ function getProxyHost() {
56
+ return process.env.DOMINUSNODE_PROXY_HOST || "proxy.dominusnode.com";
57
+ }
58
+ function getProxyPort() {
59
+ const port = parseInt(process.env.DOMINUSNODE_PROXY_PORT || "8080", 10);
60
+ if (isNaN(port) || port < 1 || port > 65535)
61
+ return 8080;
62
+ return port;
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // Credential scrubbing
66
+ // ---------------------------------------------------------------------------
67
+ /** Remove any dn_live_* or dn_test_* tokens from error messages. */
68
+ function scrubCredentials(msg) {
69
+ return msg.replace(/dn_(live|test)_[A-Za-z0-9_-]+/g, "dn_$1_***REDACTED***");
70
+ }
71
+ function safeError(err) {
72
+ const raw = err instanceof Error ? err.message : String(err);
73
+ return scrubCredentials(raw);
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // Response truncation
77
+ // ---------------------------------------------------------------------------
78
+ function truncate(text, max = MAX_RESPONSE_CHARS) {
79
+ if (text.length <= max)
80
+ return text;
81
+ return text.slice(0, max) + `\n\n... [truncated, ${text.length - max} chars omitted]`;
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // SSRF Protection
85
+ // ---------------------------------------------------------------------------
86
+ const BLOCKED_HOSTNAMES = new Set([
87
+ "localhost",
88
+ "localhost.localdomain",
89
+ "ip6-localhost",
90
+ "ip6-loopback",
91
+ "[::1]",
92
+ "[::ffff:127.0.0.1]",
93
+ "0.0.0.0",
94
+ "[::]",
95
+ ]);
96
+ /**
97
+ * Normalize non-standard IPv4 representations to dotted-decimal.
98
+ * Handles: decimal integers (2130706433), hex (0x7f000001), octal octets (0177.0.0.1).
99
+ */
100
+ function normalizeIpv4(hostname) {
101
+ // Single decimal integer
102
+ if (/^\d+$/.test(hostname)) {
103
+ const n = parseInt(hostname, 10);
104
+ if (n >= 0 && n <= 0xffffffff) {
105
+ return `${(n >>> 24) & 0xff}.${(n >>> 16) & 0xff}.${(n >>> 8) & 0xff}.${n & 0xff}`;
106
+ }
107
+ }
108
+ // Hex notation
109
+ if (/^0x[0-9a-fA-F]+$/i.test(hostname)) {
110
+ const n = parseInt(hostname, 16);
111
+ if (n >= 0 && n <= 0xffffffff) {
112
+ return `${(n >>> 24) & 0xff}.${(n >>> 16) & 0xff}.${(n >>> 8) & 0xff}.${n & 0xff}`;
113
+ }
114
+ }
115
+ // Octal / mixed-radix octets
116
+ const parts = hostname.split(".");
117
+ if (parts.length === 4) {
118
+ const octets = [];
119
+ for (const part of parts) {
120
+ let val;
121
+ if (/^0x[0-9a-fA-F]+$/i.test(part)) {
122
+ val = parseInt(part, 16);
123
+ }
124
+ else if (/^0\d+$/.test(part)) {
125
+ val = parseInt(part, 8);
126
+ }
127
+ else if (/^\d+$/.test(part)) {
128
+ val = parseInt(part, 10);
129
+ }
130
+ else {
131
+ return null;
132
+ }
133
+ if (isNaN(val) || val < 0 || val > 255)
134
+ return null;
135
+ octets.push(val);
136
+ }
137
+ return octets.join(".");
138
+ }
139
+ return null;
140
+ }
141
+ function isPrivateIp(hostname) {
142
+ let ip = hostname.replace(/^\[|\]$/g, "");
143
+ // Strip IPv6 zone ID
144
+ const zoneIdx = ip.indexOf("%");
145
+ if (zoneIdx !== -1) {
146
+ ip = ip.substring(0, zoneIdx);
147
+ }
148
+ const normalized = normalizeIpv4(ip);
149
+ const checkIp = normalized ?? ip;
150
+ // IPv4 private ranges
151
+ const ipv4Match = checkIp.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
152
+ if (ipv4Match) {
153
+ const a = Number(ipv4Match[1]);
154
+ const b = Number(ipv4Match[2]);
155
+ if (a === 0)
156
+ return true;
157
+ if (a === 10)
158
+ return true;
159
+ if (a === 127)
160
+ return true;
161
+ if (a === 169 && b === 254)
162
+ return true;
163
+ if (a === 172 && b >= 16 && b <= 31)
164
+ return true;
165
+ if (a === 192 && b === 168)
166
+ return true;
167
+ if (a === 100 && b >= 64 && b <= 127)
168
+ return true;
169
+ if (a >= 224)
170
+ return true;
171
+ return false;
172
+ }
173
+ // IPv6 private ranges
174
+ const ipLower = ip.toLowerCase();
175
+ if (ipLower === "::1")
176
+ return true;
177
+ if (ipLower === "::")
178
+ return true;
179
+ if (ipLower.startsWith("fc") || ipLower.startsWith("fd"))
180
+ return true;
181
+ if (ipLower.startsWith("fe80"))
182
+ return true;
183
+ if (ipLower.startsWith("::ffff:")) {
184
+ const embedded = ipLower.slice(7);
185
+ if (embedded.includes(".")) {
186
+ return isPrivateIp(embedded);
187
+ }
188
+ const hexParts = embedded.split(":");
189
+ if (hexParts.length === 2) {
190
+ const hi = parseInt(hexParts[0], 16);
191
+ const lo = parseInt(hexParts[1], 16);
192
+ if (!isNaN(hi) && !isNaN(lo)) {
193
+ const reconstructed = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
194
+ return isPrivateIp(reconstructed);
195
+ }
196
+ }
197
+ return isPrivateIp(embedded);
198
+ }
199
+ // IPv4-compatible IPv6 (::x.x.x.x) — deprecated but still parsed
200
+ if (ipLower.startsWith("::") && !ipLower.startsWith("::ffff:")) {
201
+ const rest = ipLower.slice(2);
202
+ if (rest && rest.includes("."))
203
+ return isPrivateIp(rest);
204
+ const hexParts = rest.split(":");
205
+ if (hexParts.length === 2 && hexParts[0] && hexParts[1]) {
206
+ const hi = parseInt(hexParts[0], 16);
207
+ const lo = parseInt(hexParts[1], 16);
208
+ if (!isNaN(hi) && !isNaN(lo)) {
209
+ const reconstructed = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
210
+ return isPrivateIp(reconstructed);
211
+ }
212
+ }
213
+ }
214
+ // Teredo (2001:0000::/32) — block unconditionally
215
+ if (ipLower.startsWith("2001:0000:") || ipLower.startsWith("2001:0:"))
216
+ return true;
217
+ // 6to4 (2002::/16) — block unconditionally
218
+ if (ipLower.startsWith("2002:"))
219
+ return true;
220
+ // IPv6 multicast (ff00::/8)
221
+ if (ipLower.startsWith("ff"))
222
+ return true;
223
+ return false;
224
+ }
225
+ /**
226
+ * Validate a URL for safety before proxying.
227
+ * Blocks: private IPs, localhost, .local/.internal/.arpa, embedded credentials, non-http(s).
228
+ */
229
+ function validateTargetUrl(url) {
230
+ let parsed;
231
+ try {
232
+ parsed = new URL(url);
233
+ }
234
+ catch {
235
+ throw new Error(`Invalid URL: ${scrubCredentials(url)}`);
236
+ }
237
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
238
+ throw new Error(`Only http: and https: protocols are supported, got ${parsed.protocol}`);
239
+ }
240
+ // Block embedded credentials (user:pass@host)
241
+ if (parsed.username || parsed.password) {
242
+ throw new Error("URLs with embedded credentials are not allowed");
243
+ }
244
+ const hostname = parsed.hostname.toLowerCase();
245
+ if (BLOCKED_HOSTNAMES.has(hostname)) {
246
+ throw new Error("Requests to localhost/loopback addresses are blocked");
247
+ }
248
+ if (isPrivateIp(hostname)) {
249
+ throw new Error("Requests to private/internal IP addresses are blocked");
250
+ }
251
+ // Block .localhost TLD (RFC 6761)
252
+ if (hostname.endsWith(".localhost")) {
253
+ throw new Error("Requests to localhost/loopback addresses are blocked");
254
+ }
255
+ // Block internal network TLDs
256
+ if (hostname.endsWith(".local") ||
257
+ hostname.endsWith(".internal") ||
258
+ hostname.endsWith(".arpa")) {
259
+ throw new Error("Requests to internal network hostnames are blocked");
260
+ }
261
+ return parsed;
262
+ }
263
+ /**
264
+ * Validate a country code: must be 2 uppercase letters and not OFAC sanctioned.
265
+ */
266
+ function validateCountry(country) {
267
+ if (!country)
268
+ return undefined;
269
+ const upper = country.toUpperCase().trim();
270
+ if (!COUNTRY_CODE_RE.test(upper)) {
271
+ throw new Error(`Invalid country code: "${country}". Must be a 2-letter ISO 3166-1 code.`);
272
+ }
273
+ if (SANCTIONED_COUNTRIES.has(upper)) {
274
+ throw new Error(`Country "${upper}" is OFAC sanctioned and cannot be used as a proxy target.`);
275
+ }
276
+ return upper;
277
+ }
278
+ /**
279
+ * Validate a UUID string.
280
+ */
281
+ function validateUuid(id, label) {
282
+ const trimmed = id.trim();
283
+ if (!UUID_RE.test(trimmed)) {
284
+ throw new Error(`Invalid ${label}: must be a valid UUID.`);
285
+ }
286
+ return trimmed;
287
+ }
288
+ // ---------------------------------------------------------------------------
289
+ // DNS rebinding protection
290
+ // ---------------------------------------------------------------------------
291
+ /**
292
+ * Resolve a hostname via DNS and verify none of the resolved IPs are private.
293
+ * Skips check if the hostname is already an IP literal.
294
+ */
295
+ async function checkDnsRebinding(hostname) {
296
+ // If hostname is already an IP literal (v4 or bracketed v6), skip DNS resolution
297
+ const stripped = hostname.replace(/^\[|\]$/g, "");
298
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(stripped) || stripped.includes(":")) {
299
+ return;
300
+ }
301
+ const resolvedIps = [];
302
+ try {
303
+ const ipv4s = await dns.resolve4(hostname);
304
+ resolvedIps.push(...ipv4s);
305
+ }
306
+ catch {
307
+ // NODATA or NXDOMAIN for A records is fine, continue to AAAA
308
+ }
309
+ try {
310
+ const ipv6s = await dns.resolve6(hostname);
311
+ resolvedIps.push(...ipv6s);
312
+ }
313
+ catch {
314
+ // NODATA or NXDOMAIN for AAAA records is fine
315
+ }
316
+ for (const ip of resolvedIps) {
317
+ if (isPrivateIp(ip)) {
318
+ throw new Error(`DNS rebinding detected: hostname resolves to private IP ${ip}`);
319
+ }
320
+ }
321
+ }
322
+ // ---------------------------------------------------------------------------
323
+ // Prototype pollution prevention (recursive)
324
+ // ---------------------------------------------------------------------------
325
+ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
326
+ function stripDangerousKeys(obj, depth = 0) {
327
+ if (depth > 50 || !obj || typeof obj !== "object")
328
+ return;
329
+ if (Array.isArray(obj)) {
330
+ for (const item of obj)
331
+ stripDangerousKeys(item, depth + 1);
332
+ return;
333
+ }
334
+ const record = obj;
335
+ for (const key of Object.keys(record)) {
336
+ if (DANGEROUS_KEYS.has(key)) {
337
+ delete record[key];
338
+ }
339
+ else if (record[key] && typeof record[key] === "object") {
340
+ stripDangerousKeys(record[key], depth + 1);
341
+ }
342
+ }
343
+ }
344
+ // ---------------------------------------------------------------------------
345
+ // Lazy JWT authentication
346
+ // ---------------------------------------------------------------------------
347
+ let cachedJwt = null;
348
+ let jwtExpiresAt = 0;
349
+ let cachedApiKeyPrefix = null;
350
+ async function ensureAuth(apiKey, baseUrl) {
351
+ const keyPrefix = apiKey.slice(0, 16);
352
+ if (cachedJwt && Date.now() < jwtExpiresAt && cachedApiKeyPrefix === keyPrefix)
353
+ return cachedJwt;
354
+ const res = await fetch(`${baseUrl}/api/auth/verify-key`, {
355
+ method: "POST",
356
+ headers: { "Content-Type": "application/json", "User-Agent": "dominusnode-openclaw-plugin/1.0.0" },
357
+ body: JSON.stringify({ apiKey }),
358
+ redirect: "error",
359
+ });
360
+ if (!res.ok) {
361
+ const text = await res.text().catch(() => "");
362
+ throw new Error(`Auth failed (${res.status}): ${scrubCredentials(text.slice(0, 500))}`);
363
+ }
364
+ const data = await res.json();
365
+ cachedApiKeyPrefix = keyPrefix;
366
+ cachedJwt = data.token;
367
+ // JWT expires in 15 min, refresh at 14 min for safety
368
+ jwtExpiresAt = Date.now() + 14 * 60 * 1000;
369
+ return cachedJwt;
370
+ }
371
+ async function apiRequest(method, path, body) {
372
+ const apiKey = getApiKey();
373
+ const baseUrl = getBaseUrl();
374
+ const url = `${baseUrl}${path}`;
375
+ const jwt = await ensureAuth(apiKey, baseUrl);
376
+ const headers = {
377
+ "Authorization": `Bearer ${jwt}`,
378
+ "Accept": "application/json",
379
+ "User-Agent": "dominusnode-openclaw-plugin/1.0.0",
380
+ };
381
+ const init = {
382
+ method,
383
+ headers,
384
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
385
+ redirect: "error",
386
+ };
387
+ if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
388
+ headers["Content-Type"] = "application/json";
389
+ init.body = JSON.stringify(body);
390
+ }
391
+ let res;
392
+ try {
393
+ res = await fetch(url, init);
394
+ }
395
+ catch (err) {
396
+ throw new Error(`API request failed: ${safeError(err)}`);
397
+ }
398
+ // Read body as text first, then parse (safeJson pattern)
399
+ let rawText;
400
+ try {
401
+ rawText = await res.text();
402
+ }
403
+ catch {
404
+ rawText = "";
405
+ }
406
+ if (new TextEncoder().encode(rawText).length > MAX_RESPONSE_BYTES) {
407
+ throw new Error("API response too large");
408
+ }
409
+ if (!res.ok) {
410
+ let errorMsg = `API error ${res.status}`;
411
+ if (rawText) {
412
+ try {
413
+ const parsed = JSON.parse(rawText);
414
+ stripDangerousKeys(parsed);
415
+ if (parsed.error) {
416
+ errorMsg = `API error ${res.status}: ${scrubCredentials(String(parsed.error))}`;
417
+ }
418
+ else if (parsed.message) {
419
+ errorMsg = `API error ${res.status}: ${scrubCredentials(String(parsed.message))}`;
420
+ }
421
+ }
422
+ catch {
423
+ errorMsg = `API error ${res.status}: ${scrubCredentials(rawText.slice(0, 200))}`;
424
+ }
425
+ }
426
+ throw new Error(errorMsg);
427
+ }
428
+ if (!rawText || rawText.trim().length === 0) {
429
+ return {};
430
+ }
431
+ try {
432
+ const parsed = JSON.parse(rawText);
433
+ stripDangerousKeys(parsed);
434
+ return parsed;
435
+ }
436
+ catch {
437
+ throw new Error("Failed to parse API response as JSON");
438
+ }
439
+ }
440
+ async function apiGet(path) {
441
+ return apiRequest("GET", path);
442
+ }
443
+ async function apiPost(path, body) {
444
+ return apiRequest("POST", path, body);
445
+ }
446
+ async function apiDelete(path) {
447
+ return apiRequest("DELETE", path);
448
+ }
449
+ async function apiPatch(path, body) {
450
+ return apiRequest("PATCH", path, body);
451
+ }
452
+ // ---------------------------------------------------------------------------
453
+ // Formatting helpers
454
+ // ---------------------------------------------------------------------------
455
+ function formatBytes(bytes) {
456
+ if (bytes < 1024)
457
+ return `${bytes} B`;
458
+ if (bytes < 1024 * 1024)
459
+ return `${(bytes / 1024).toFixed(1)} KB`;
460
+ if (bytes < 1024 * 1024 * 1024)
461
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
462
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(3)} GB`;
463
+ }
464
+ function formatCents(cents) {
465
+ return `$${(cents / 100).toFixed(2)}`;
466
+ }
467
+ // ---------------------------------------------------------------------------
468
+ // Tool implementations
469
+ // ---------------------------------------------------------------------------
470
+ // 1. proxied_fetch
471
+ const proxiedFetchTool = {
472
+ name: "proxied_fetch",
473
+ description: "Fetch a URL through DomiNode's rotating proxy network. Supports geo-targeting " +
474
+ "by country. Returns status code, headers, and response body (truncated to 4000 chars).",
475
+ parameters: {
476
+ url: {
477
+ type: "string",
478
+ description: "The URL to fetch (http or https)",
479
+ required: true,
480
+ },
481
+ method: {
482
+ type: "string",
483
+ description: "HTTP method",
484
+ required: false,
485
+ enum: ["GET", "HEAD", "OPTIONS"],
486
+ default: "GET",
487
+ },
488
+ country: {
489
+ type: "string",
490
+ description: "ISO 3166-1 alpha-2 country code for geo-targeting (e.g., US, GB, DE)",
491
+ required: false,
492
+ },
493
+ pool: {
494
+ type: "string",
495
+ description: "Proxy pool to use: dc (datacenter, $3/GB) or residential ($5/GB)",
496
+ required: false,
497
+ enum: ["dc", "residential"],
498
+ default: "dc",
499
+ },
500
+ headers: {
501
+ type: "object",
502
+ description: "Additional HTTP headers to include in the request (JSON object of key-value pairs)",
503
+ required: false,
504
+ },
505
+ },
506
+ execute: async (args) => {
507
+ try {
508
+ const url = String(args.url ?? "");
509
+ if (!url)
510
+ return "Error: url parameter is required.";
511
+ const parsedUrl = validateTargetUrl(url);
512
+ await checkDnsRebinding(parsedUrl.hostname);
513
+ const country = validateCountry(args.country);
514
+ const method = String(args.method ?? "GET").toUpperCase();
515
+ const ALLOWED_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
516
+ if (!ALLOWED_METHODS.has(method)) {
517
+ return `Error: HTTP method "${method}" is not allowed. Only GET, HEAD, and OPTIONS are permitted.`;
518
+ }
519
+ const proxyType = String(args.pool ?? "dc");
520
+ // Validate and collect custom headers
521
+ const STRIPPED_HEADERS = new Set(["host", "connection", "content-length", "transfer-encoding", "proxy-authorization", "authorization", "user-agent"]);
522
+ const customHeaders = {};
523
+ if (args.headers && typeof args.headers === "object" && !Array.isArray(args.headers)) {
524
+ for (const [k, v] of Object.entries(args.headers)) {
525
+ const key = String(k);
526
+ const val = String(v ?? "");
527
+ if (/[\r\n\0]/.test(key) || /[\r\n\0]/.test(val)) {
528
+ return `Error: Header "${key.replace(/[\r\n\0]/g, "")}" contains invalid characters (CR, LF, or NUL).`;
529
+ }
530
+ if (!STRIPPED_HEADERS.has(key.toLowerCase())) {
531
+ customHeaders[key] = val;
532
+ }
533
+ }
534
+ }
535
+ const proxyHost = getProxyHost();
536
+ const proxyPort = getProxyPort();
537
+ const apiKey = getApiKey();
538
+ const parts = [];
539
+ if (proxyType && proxyType !== "auto")
540
+ parts.push(proxyType);
541
+ if (country)
542
+ parts.push(`country-${country.toUpperCase()}`);
543
+ const username = parts.length > 0 ? parts.join("-") : "auto";
544
+ const proxyAuth = "Basic " + Buffer.from(`${username}:${apiKey}`).toString("base64");
545
+ const parsed = new URL(url);
546
+ const MAX_RESP = 1_048_576; // 1MB
547
+ // Build custom header lines for raw HTTP request
548
+ const customHeaderLines = Object.entries(customHeaders).map(([k, v]) => `${k}: ${v}\r\n`).join("");
549
+ const result = await new Promise((resolve, reject) => {
550
+ const timer = setTimeout(() => reject(new Error("Proxy request timed out")), 30_000);
551
+ if (parsed.protocol === "https:") {
552
+ const connectHost = parsed.hostname.includes(":") ? `[${parsed.hostname}]` : parsed.hostname;
553
+ const connectReq = http.request({
554
+ hostname: proxyHost, port: proxyPort, method: "CONNECT",
555
+ path: `${connectHost}:${parsed.port || 443}`,
556
+ headers: { "Proxy-Authorization": proxyAuth, Host: `${connectHost}:${parsed.port || 443}` },
557
+ });
558
+ connectReq.on("connect", (_res, sock) => {
559
+ if (_res.statusCode !== 200) {
560
+ clearTimeout(timer);
561
+ sock.destroy();
562
+ reject(new Error(`CONNECT failed: ${_res.statusCode}`));
563
+ return;
564
+ }
565
+ const tlsSock = tls.connect({ host: parsed.hostname, socket: sock, servername: parsed.hostname, minVersion: "TLSv1.2" }, () => {
566
+ const reqLine = `${method} ${parsed.pathname + parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\nUser-Agent: dominusnode-openclaw/1.0.0\r\n${customHeaderLines}Connection: close\r\n\r\n`;
567
+ tlsSock.write(reqLine);
568
+ const chunks = [];
569
+ let bytes = 0;
570
+ tlsSock.on("data", (c) => { bytes += c.length; if (bytes <= MAX_RESP + 16384)
571
+ chunks.push(c); });
572
+ let done = false;
573
+ const fin = () => {
574
+ if (done)
575
+ return;
576
+ done = true;
577
+ clearTimeout(timer);
578
+ const raw = Buffer.concat(chunks).toString("utf-8");
579
+ const hEnd = raw.indexOf("\r\n\r\n");
580
+ if (hEnd === -1) {
581
+ reject(new Error("Malformed response"));
582
+ return;
583
+ }
584
+ const hdr = raw.substring(0, hEnd);
585
+ const body = raw.substring(hEnd + 4).substring(0, MAX_RESP);
586
+ const sm = hdr.split("\r\n")[0].match(/^HTTP\/\d\.\d\s+(\d+)/);
587
+ const hdrs = {};
588
+ for (const l of hdr.split("\r\n").slice(1)) {
589
+ const ci = l.indexOf(":");
590
+ if (ci > 0)
591
+ hdrs[l.substring(0, ci).trim().toLowerCase()] = l.substring(ci + 1).trim();
592
+ }
593
+ resolve({ status: sm ? parseInt(sm[1], 10) : 0, headers: hdrs, body });
594
+ };
595
+ tlsSock.on("end", fin);
596
+ tlsSock.on("close", fin);
597
+ tlsSock.on("error", (e) => { clearTimeout(timer); reject(e); });
598
+ });
599
+ tlsSock.on("error", (e) => { clearTimeout(timer); reject(e); });
600
+ });
601
+ connectReq.on("error", (e) => { clearTimeout(timer); reject(e); });
602
+ connectReq.end();
603
+ }
604
+ else {
605
+ const req = http.request({
606
+ hostname: proxyHost, port: proxyPort, method, path: url,
607
+ headers: { "Proxy-Authorization": proxyAuth, Host: parsed.host ?? "", ...customHeaders },
608
+ }, (res) => {
609
+ const chunks = [];
610
+ let bytes = 0;
611
+ res.on("data", (c) => { bytes += c.length; if (bytes <= MAX_RESP)
612
+ chunks.push(c); });
613
+ let done = false;
614
+ const fin = () => {
615
+ if (done)
616
+ return;
617
+ done = true;
618
+ clearTimeout(timer);
619
+ const body = Buffer.concat(chunks).toString("utf-8").substring(0, MAX_RESP);
620
+ const hdrs = {};
621
+ for (const [k, v] of Object.entries(res.headers)) {
622
+ if (v)
623
+ hdrs[k] = Array.isArray(v) ? v.join(", ") : v;
624
+ }
625
+ resolve({ status: res.statusCode ?? 0, headers: hdrs, body });
626
+ };
627
+ res.on("end", fin);
628
+ res.on("close", fin);
629
+ res.on("error", (e) => { clearTimeout(timer); reject(e); });
630
+ });
631
+ req.on("error", (e) => { clearTimeout(timer); reject(e); });
632
+ req.end();
633
+ }
634
+ });
635
+ const lines = [
636
+ `HTTP ${result.status}`,
637
+ `Pool: ${proxyType} | Country: ${country ?? "auto"}`,
638
+ "",
639
+ ];
640
+ // Include relevant response headers
641
+ if (result.headers) {
642
+ const showHeaders = ["content-type", "content-length", "server", "x-cache", "cache-control"];
643
+ for (const h of showHeaders) {
644
+ if (result.headers[h]) {
645
+ lines.push(`${h}: ${result.headers[h]}`);
646
+ }
647
+ }
648
+ lines.push("");
649
+ }
650
+ if (result.body) {
651
+ lines.push(truncate(result.body));
652
+ }
653
+ return scrubCredentials(truncate(lines.join("\n")));
654
+ }
655
+ catch (err) {
656
+ return `Error: ${safeError(err)}`;
657
+ }
658
+ },
659
+ };
660
+ // 2. check_balance
661
+ const checkBalanceTool = {
662
+ name: "check_balance",
663
+ description: "Check your DomiNode wallet balance and estimated remaining bandwidth at current pricing.",
664
+ parameters: {},
665
+ execute: async () => {
666
+ try {
667
+ const data = await apiGet("/api/wallet");
668
+ const balanceCents = data.balanceCents ?? 0;
669
+ const balanceUsd = data.balanceUsd ?? balanceCents / 100;
670
+ const dcGbRemaining = (balanceCents / 300).toFixed(2);
671
+ const resGbRemaining = (balanceCents / 500).toFixed(2);
672
+ return [
673
+ `Wallet Balance: $${balanceUsd.toFixed(2)}`,
674
+ "",
675
+ "Estimated remaining bandwidth:",
676
+ ` Datacenter ($3/GB): ~${dcGbRemaining} GB`,
677
+ ` Residential ($5/GB): ~${resGbRemaining} GB`,
678
+ "",
679
+ "Use check_usage to see recent consumption.",
680
+ ].join("\n");
681
+ }
682
+ catch (err) {
683
+ return `Error: ${safeError(err)}`;
684
+ }
685
+ },
686
+ };
687
+ // 3. check_usage
688
+ const checkUsageTool = {
689
+ name: "check_usage",
690
+ description: "View bandwidth usage statistics for a specified time period. Shows total bytes, cost, and request count.",
691
+ parameters: {
692
+ days: {
693
+ type: "number",
694
+ description: "Number of days to look back (1-365)",
695
+ required: false,
696
+ default: 30,
697
+ },
698
+ },
699
+ execute: async (args) => {
700
+ try {
701
+ const days = Math.min(Math.max(Number(args.days ?? 30), 1), 365);
702
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
703
+ const until = new Date().toISOString();
704
+ const data = await apiGet(`/api/usage?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}`);
705
+ const s = data.summary;
706
+ return [
707
+ `Usage Summary (last ${days} days):`,
708
+ ` Total Bandwidth: ${formatBytes(s.totalBytes)}`,
709
+ ` Total Cost: $${s.totalCostUsd.toFixed(2)}`,
710
+ ` Total Requests: ${s.requestCount}`,
711
+ "",
712
+ "Use check_balance to see remaining funds.",
713
+ ].join("\n");
714
+ }
715
+ catch (err) {
716
+ return `Error: ${safeError(err)}`;
717
+ }
718
+ },
719
+ };
720
+ // 4. get_proxy_config
721
+ const getProxyConfigTool = {
722
+ name: "get_proxy_config",
723
+ description: "Get available proxy pools, pricing, supported countries, and endpoint configuration.",
724
+ parameters: {},
725
+ execute: async () => {
726
+ try {
727
+ const data = await apiGet("/api/proxy/config");
728
+ const lines = [
729
+ "DomiNode Proxy Configuration",
730
+ "",
731
+ `Proxy Host: ${data.proxyHost ?? "proxy.dominusnode.com"}`,
732
+ `HTTP Proxy Port: ${data.httpProxyPort ?? 8080}`,
733
+ `SOCKS5 Proxy Port: ${data.socks5ProxyPort ?? 1080}`,
734
+ "",
735
+ "Available Pools:",
736
+ ];
737
+ if (data.pools) {
738
+ for (const pool of data.pools) {
739
+ lines.push(` ${pool.name} (${pool.type}): ${formatCents(pool.pricePerGbCents)}/GB`);
740
+ if (pool.countries && pool.countries.length > 0) {
741
+ lines.push(` Countries: ${pool.countries.slice(0, 20).join(", ")}${pool.countries.length > 20 ? "..." : ""}`);
742
+ }
743
+ }
744
+ }
745
+ else {
746
+ lines.push(" Datacenter (dc): $3.00/GB");
747
+ lines.push(" Residential (residential): $5.00/GB");
748
+ }
749
+ lines.push("");
750
+ lines.push("Blocked countries (OFAC): CU, IR, KP, RU, SY");
751
+ return lines.join("\n");
752
+ }
753
+ catch (err) {
754
+ return `Error: ${safeError(err)}`;
755
+ }
756
+ },
757
+ };
758
+ // 5. list_sessions
759
+ const listSessionsTool = {
760
+ name: "list_sessions",
761
+ description: "List all active proxy sessions showing target hosts, bandwidth used, and status.",
762
+ parameters: {},
763
+ execute: async () => {
764
+ try {
765
+ const data = await apiGet("/api/sessions/active");
766
+ const sessions = data.sessions ?? [];
767
+ if (sessions.length === 0) {
768
+ return "No active proxy sessions.";
769
+ }
770
+ const lines = [`Active Sessions (${sessions.length}):`, ""];
771
+ for (const s of sessions) {
772
+ const target = s.targetHost ? ` | Target: ${s.targetHost}` : "";
773
+ const bw = s.bytesIn !== undefined || s.bytesOut !== undefined
774
+ ? ` | In: ${formatBytes(s.bytesIn ?? 0)} / Out: ${formatBytes(s.bytesOut ?? 0)}`
775
+ : "";
776
+ lines.push(` ${s.id.substring(0, 8)}... | ${s.status} | Since: ${s.startedAt}${target}${bw}`);
777
+ }
778
+ return truncate(lines.join("\n"));
779
+ }
780
+ catch (err) {
781
+ return `Error: ${safeError(err)}`;
782
+ }
783
+ },
784
+ };
785
+ // 6. create_agentic_wallet
786
+ const createAgenticWalletTool = {
787
+ name: "create_agentic_wallet",
788
+ description: "Create a server-side custodial agentic wallet for autonomous proxy billing. " +
789
+ "Set a spending limit per transaction for safety. Fund it from your main wallet.",
790
+ parameters: {
791
+ label: {
792
+ type: "string",
793
+ description: 'Label for this wallet (e.g., "scraper-bot", "research-agent")',
794
+ required: true,
795
+ },
796
+ spending_limit_cents: {
797
+ type: "number",
798
+ description: "Max spend per transaction in cents (0 = no limit, default $100 = 10000 cents)",
799
+ required: false,
800
+ default: 10000,
801
+ },
802
+ daily_limit_cents: {
803
+ type: "number",
804
+ description: "Optional daily spending budget in cents, resets midnight UTC (1-1000000)",
805
+ required: false,
806
+ },
807
+ allowed_domains: {
808
+ type: "array",
809
+ description: "Optional domain allowlist for proxy requests (max 100, each <= 253 chars, e.g. ['example.com'])",
810
+ required: false,
811
+ },
812
+ },
813
+ execute: async (args) => {
814
+ try {
815
+ const label = String(args.label ?? "").trim();
816
+ if (!label || label.length === 0 || label.length > 100) {
817
+ return "Error: label is required and must be 1-100 characters.";
818
+ }
819
+ // Sanitize label: strip control characters
820
+ if (/[\x00-\x1f\x7f]/.test(label)) {
821
+ return "Error: label contains invalid control characters.";
822
+ }
823
+ const spendingLimitCents = Math.min(Math.max(Number(args.spending_limit_cents ?? 10000), 0), 1000000);
824
+ const body = {
825
+ label,
826
+ spendingLimitCents,
827
+ };
828
+ // Validate optional daily_limit_cents
829
+ if (args.daily_limit_cents !== undefined && args.daily_limit_cents !== null) {
830
+ const dailyLimit = Number(args.daily_limit_cents);
831
+ if (!Number.isInteger(dailyLimit) || dailyLimit < 1 || dailyLimit > 1000000) {
832
+ return "Error: daily_limit_cents must be a positive integer between 1 and 1000000.";
833
+ }
834
+ body.dailyLimitCents = dailyLimit;
835
+ }
836
+ // Validate optional allowed_domains
837
+ if (args.allowed_domains !== undefined && args.allowed_domains !== null) {
838
+ if (!Array.isArray(args.allowed_domains)) {
839
+ return "Error: allowed_domains must be an array of domain strings.";
840
+ }
841
+ if (args.allowed_domains.length > 100) {
842
+ return "Error: allowed_domains must have at most 100 entries.";
843
+ }
844
+ for (let i = 0; i < args.allowed_domains.length; i++) {
845
+ const d = args.allowed_domains[i];
846
+ if (typeof d !== "string") {
847
+ return `Error: allowed_domains[${i}] must be a string.`;
848
+ }
849
+ if (d.length > 253) {
850
+ return `Error: allowed_domains[${i}] exceeds 253 characters.`;
851
+ }
852
+ if (!DOMAIN_RE.test(d)) {
853
+ return `Error: allowed_domains[${i}] is not a valid domain: ${d}`;
854
+ }
855
+ }
856
+ body.allowedDomains = args.allowed_domains;
857
+ }
858
+ const data = await apiPost("/api/agent-wallet", body);
859
+ return [
860
+ "Agentic Wallet Created",
861
+ "",
862
+ `Wallet ID: ${data.id}`,
863
+ `Label: ${data.label}`,
864
+ `Balance: ${formatCents(data.balanceCents)}`,
865
+ `Spending Limit: ${data.spendingLimitCents > 0 ? `${formatCents(data.spendingLimitCents)} per transaction` : "No limit"}`,
866
+ `Status: ${data.status}`,
867
+ "",
868
+ "Next steps:",
869
+ " 1. Use fund_agentic_wallet to add funds from your main wallet",
870
+ " 2. Use check_agentic_balance to verify the balance",
871
+ ].join("\n");
872
+ }
873
+ catch (err) {
874
+ return `Error: ${safeError(err)}`;
875
+ }
876
+ },
877
+ };
878
+ // 7. fund_agentic_wallet
879
+ const fundAgenticWalletTool = {
880
+ name: "fund_agentic_wallet",
881
+ description: "Transfer funds from your main wallet to an agentic wallet. Minimum $1 (100 cents), maximum $10,000 (1000000 cents).",
882
+ parameters: {
883
+ wallet_id: {
884
+ type: "string",
885
+ description: "Agentic wallet ID (UUID)",
886
+ required: true,
887
+ },
888
+ amount_cents: {
889
+ type: "number",
890
+ description: "Amount in cents to transfer (min 100, max 1000000)",
891
+ required: true,
892
+ },
893
+ },
894
+ execute: async (args) => {
895
+ try {
896
+ const walletId = validateUuid(String(args.wallet_id ?? ""), "wallet_id");
897
+ const amountCents = Number(args.amount_cents ?? 0);
898
+ if (!Number.isInteger(amountCents) || amountCents < 100 || amountCents > 1000000) {
899
+ return "Error: amount_cents must be an integer between 100 ($1) and 1000000 ($10,000).";
900
+ }
901
+ const data = await apiPost(`/api/agent-wallet/${encodeURIComponent(walletId)}/fund`, {
902
+ amountCents,
903
+ });
904
+ const tx = data.transaction;
905
+ return [
906
+ "Wallet Funded Successfully",
907
+ "",
908
+ `Transaction ID: ${tx.id}`,
909
+ `Amount: ${formatCents(tx.amountCents)}`,
910
+ `Type: ${tx.type}`,
911
+ "",
912
+ "The funds have been transferred from your main wallet.",
913
+ "Use check_agentic_balance to verify the new balance.",
914
+ ].join("\n");
915
+ }
916
+ catch (err) {
917
+ return `Error: ${safeError(err)}`;
918
+ }
919
+ },
920
+ };
921
+ // 8. check_agentic_balance
922
+ const checkAgenticBalanceTool = {
923
+ name: "check_agentic_balance",
924
+ description: "Check the balance and details of an agentic wallet.",
925
+ parameters: {
926
+ wallet_id: {
927
+ type: "string",
928
+ description: "Agentic wallet ID (UUID)",
929
+ required: true,
930
+ },
931
+ },
932
+ execute: async (args) => {
933
+ try {
934
+ const walletId = validateUuid(String(args.wallet_id ?? ""), "wallet_id");
935
+ const data = await apiGet(`/api/agent-wallet/${encodeURIComponent(walletId)}`);
936
+ return [
937
+ `Agentic Wallet: ${data.label}`,
938
+ "",
939
+ `Wallet ID: ${data.id}`,
940
+ `Balance: ${formatCents(data.balanceCents)}`,
941
+ `Spending Limit: ${data.spendingLimitCents > 0 ? `${formatCents(data.spendingLimitCents)} per tx` : "No limit"}`,
942
+ `Status: ${data.status}`,
943
+ `Created: ${data.createdAt}`,
944
+ ].join("\n");
945
+ }
946
+ catch (err) {
947
+ return `Error: ${safeError(err)}`;
948
+ }
949
+ },
950
+ };
951
+ // 9. list_agentic_wallets
952
+ const listAgenticWalletsTool = {
953
+ name: "list_agentic_wallets",
954
+ description: "List all your agentic wallets with balances and status.",
955
+ parameters: {},
956
+ execute: async () => {
957
+ try {
958
+ const data = await apiGet("/api/agent-wallet");
959
+ const wallets = data.wallets ?? [];
960
+ if (wallets.length === 0) {
961
+ return "No agentic wallets found. Use create_agentic_wallet to create one.";
962
+ }
963
+ const lines = [`Agentic Wallets (${wallets.length})`, ""];
964
+ for (const w of wallets) {
965
+ const limit = w.spendingLimitCents > 0
966
+ ? `${formatCents(w.spendingLimitCents)}/tx`
967
+ : "none";
968
+ lines.push(` ${w.label} (${w.id.slice(0, 8)}...)`);
969
+ lines.push(` Balance: ${formatCents(w.balanceCents)} | Limit: ${limit} | Status: ${w.status}`);
970
+ lines.push("");
971
+ }
972
+ return truncate(lines.join("\n"));
973
+ }
974
+ catch (err) {
975
+ return `Error: ${safeError(err)}`;
976
+ }
977
+ },
978
+ };
979
+ // 10. agentic_transactions
980
+ const agenticTransactionsTool = {
981
+ name: "agentic_transactions",
982
+ description: "Get transaction history for an agentic wallet.",
983
+ parameters: {
984
+ wallet_id: {
985
+ type: "string",
986
+ description: "Agentic wallet ID (UUID)",
987
+ required: true,
988
+ },
989
+ limit: {
990
+ type: "number",
991
+ description: "Number of transactions to return (1-100, default 20)",
992
+ required: false,
993
+ default: 20,
994
+ },
995
+ },
996
+ execute: async (args) => {
997
+ try {
998
+ const walletId = validateUuid(String(args.wallet_id ?? ""), "wallet_id");
999
+ const limit = Math.min(Math.max(Number(args.limit ?? 20), 1), 100);
1000
+ const data = await apiGet(`/api/agent-wallet/${encodeURIComponent(walletId)}/transactions?limit=${limit}`);
1001
+ const txs = data.transactions ?? [];
1002
+ if (txs.length === 0) {
1003
+ return "No transactions found for this wallet.";
1004
+ }
1005
+ const lines = [`Wallet Transactions (${txs.length})`, ""];
1006
+ for (const tx of txs) {
1007
+ const sign = tx.type === "fund" || tx.type === "refund" ? "+" : "-";
1008
+ const session = tx.sessionId ? ` | Session: ${tx.sessionId.slice(0, 8)}` : "";
1009
+ lines.push(` ${sign}${formatCents(tx.amountCents)} [${tx.type}] ${tx.description}`);
1010
+ lines.push(` ${tx.createdAt}${session}`);
1011
+ }
1012
+ return truncate(lines.join("\n"));
1013
+ }
1014
+ catch (err) {
1015
+ return `Error: ${safeError(err)}`;
1016
+ }
1017
+ },
1018
+ };
1019
+ // 11. create_team
1020
+ const createTeamTool = {
1021
+ name: "create_team",
1022
+ description: "Create a new team with shared wallet billing. Teams allow multiple users to share proxy access and costs.",
1023
+ parameters: {
1024
+ name: {
1025
+ type: "string",
1026
+ description: "Team name (1-100 characters)",
1027
+ required: true,
1028
+ },
1029
+ max_members: {
1030
+ type: "number",
1031
+ description: "Maximum number of team members (optional, no limit by default)",
1032
+ required: false,
1033
+ },
1034
+ },
1035
+ execute: async (args) => {
1036
+ try {
1037
+ const name = String(args.name ?? "").trim();
1038
+ if (!name || name.length === 0 || name.length > 100) {
1039
+ return "Error: name is required and must be 1-100 characters.";
1040
+ }
1041
+ if (/[\x00-\x1f\x7f]/.test(name)) {
1042
+ return "Error: team name contains invalid control characters.";
1043
+ }
1044
+ const body = { name };
1045
+ if (args.max_members !== undefined) {
1046
+ const maxMembers = Number(args.max_members);
1047
+ if (!Number.isInteger(maxMembers) || maxMembers < 1 || maxMembers > 100) {
1048
+ return "Error: max_members must be an integer between 1 and 100.";
1049
+ }
1050
+ body.maxMembers = maxMembers;
1051
+ }
1052
+ const data = await apiPost("/api/teams", body);
1053
+ return [
1054
+ "Team Created",
1055
+ "",
1056
+ `Team ID: ${data.id}`,
1057
+ `Name: ${data.name}`,
1058
+ `Max Members: ${data.maxMembers ?? "Unlimited"}`,
1059
+ `Balance: ${formatCents(data.balanceCents ?? 0)}`,
1060
+ `Created: ${data.createdAt}`,
1061
+ "",
1062
+ "Next steps:",
1063
+ " 1. Use team_fund to add funds from your personal wallet",
1064
+ " 2. Use team_create_key to create a shared API key",
1065
+ " 3. Share the team with colleagues via the dashboard",
1066
+ ].join("\n");
1067
+ }
1068
+ catch (err) {
1069
+ return `Error: ${safeError(err)}`;
1070
+ }
1071
+ },
1072
+ };
1073
+ // 12. list_teams
1074
+ const listTeamsTool = {
1075
+ name: "list_teams",
1076
+ description: "List all teams you belong to, with your role and team balance.",
1077
+ parameters: {},
1078
+ execute: async () => {
1079
+ try {
1080
+ const data = await apiGet("/api/teams");
1081
+ const teams = data.teams ?? [];
1082
+ if (teams.length === 0) {
1083
+ return "No teams found. Use create_team to create one.";
1084
+ }
1085
+ const lines = [`Teams (${teams.length})`, ""];
1086
+ for (const t of teams) {
1087
+ const memberInfo = t.memberCount !== undefined ? ` | Members: ${t.memberCount}` : "";
1088
+ lines.push(` ${t.name} (${t.id.slice(0, 8)}...)`);
1089
+ lines.push(` Role: ${t.role} | Balance: ${formatCents(t.balanceCents)}${memberInfo}`);
1090
+ lines.push("");
1091
+ }
1092
+ return truncate(lines.join("\n"));
1093
+ }
1094
+ catch (err) {
1095
+ return `Error: ${safeError(err)}`;
1096
+ }
1097
+ },
1098
+ };
1099
+ // 13. team_details
1100
+ const teamDetailsTool = {
1101
+ name: "team_details",
1102
+ description: "Get detailed info about a team including balance, members, settings, and your role.",
1103
+ parameters: {
1104
+ team_id: {
1105
+ type: "string",
1106
+ description: "Team ID (UUID)",
1107
+ required: true,
1108
+ },
1109
+ },
1110
+ execute: async (args) => {
1111
+ try {
1112
+ const teamId = validateUuid(String(args.team_id ?? ""), "team_id");
1113
+ const data = await apiGet(`/api/teams/${encodeURIComponent(teamId)}`);
1114
+ const lines = [
1115
+ `Team: ${data.name}`,
1116
+ "",
1117
+ `Team ID: ${data.id}`,
1118
+ `Owner: ${data.ownerId}`,
1119
+ `Status: ${data.status}`,
1120
+ `Your Role: ${data.role}`,
1121
+ `Balance: ${formatCents(data.balanceCents)}`,
1122
+ `Max Members: ${data.maxMembers ?? "Unlimited"}`,
1123
+ `Created: ${data.createdAt}`,
1124
+ ];
1125
+ const members = data.members ?? [];
1126
+ if (members.length > 0) {
1127
+ lines.push("", `Members (${members.length}):`);
1128
+ for (const m of members) {
1129
+ lines.push(` ${m.email} -- ${m.role} (joined ${m.joinedAt})`);
1130
+ }
1131
+ }
1132
+ return truncate(lines.join("\n"));
1133
+ }
1134
+ catch (err) {
1135
+ return `Error: ${safeError(err)}`;
1136
+ }
1137
+ },
1138
+ };
1139
+ // 14. team_fund
1140
+ const teamFundTool = {
1141
+ name: "team_fund",
1142
+ description: "Transfer funds from your personal wallet to a team wallet. Minimum $1 (100 cents), maximum $10,000.",
1143
+ parameters: {
1144
+ team_id: {
1145
+ type: "string",
1146
+ description: "Team ID (UUID)",
1147
+ required: true,
1148
+ },
1149
+ amount_cents: {
1150
+ type: "number",
1151
+ description: "Amount in cents to transfer (min 100, max 1000000)",
1152
+ required: true,
1153
+ },
1154
+ },
1155
+ execute: async (args) => {
1156
+ try {
1157
+ const teamId = validateUuid(String(args.team_id ?? ""), "team_id");
1158
+ const amountCents = Number(args.amount_cents ?? 0);
1159
+ if (!Number.isInteger(amountCents) || amountCents < 100 || amountCents > 1000000) {
1160
+ return "Error: amount_cents must be an integer between 100 ($1) and 1000000 ($10,000).";
1161
+ }
1162
+ const data = await apiPost(`/api/teams/${encodeURIComponent(teamId)}/wallet/fund`, {
1163
+ amountCents,
1164
+ });
1165
+ const tx = data.transaction;
1166
+ return [
1167
+ "Team Funded Successfully",
1168
+ "",
1169
+ `Transaction ID: ${tx.id}`,
1170
+ `Amount: ${formatCents(tx.amountCents)}`,
1171
+ `Type: ${tx.type}`,
1172
+ "",
1173
+ "The funds have been transferred from your personal wallet.",
1174
+ "Use team_details to check the new team balance.",
1175
+ ].join("\n");
1176
+ }
1177
+ catch (err) {
1178
+ return `Error: ${safeError(err)}`;
1179
+ }
1180
+ },
1181
+ };
1182
+ // 15. team_create_key
1183
+ const teamCreateKeyTool = {
1184
+ name: "team_create_key",
1185
+ description: "Create a shared API key for a team. Usage is billed against the team wallet. " +
1186
+ "The key is shown only once -- save it immediately.",
1187
+ parameters: {
1188
+ team_id: {
1189
+ type: "string",
1190
+ description: "Team ID (UUID)",
1191
+ required: true,
1192
+ },
1193
+ label: {
1194
+ type: "string",
1195
+ description: 'Label for the API key (e.g., "production", "staging")',
1196
+ required: true,
1197
+ },
1198
+ },
1199
+ execute: async (args) => {
1200
+ try {
1201
+ const teamId = validateUuid(String(args.team_id ?? ""), "team_id");
1202
+ const label = String(args.label ?? "").trim();
1203
+ if (!label || label.length === 0 || label.length > 100) {
1204
+ return "Error: label is required and must be 1-100 characters.";
1205
+ }
1206
+ if (/[\x00-\x1f\x7f]/.test(label)) {
1207
+ return "Error: label contains invalid control characters.";
1208
+ }
1209
+ const data = await apiPost(`/api/teams/${encodeURIComponent(teamId)}/keys`, {
1210
+ label,
1211
+ });
1212
+ return [
1213
+ "Team API Key Created",
1214
+ "",
1215
+ `Key ID: ${data.id}`,
1216
+ `API Key: ${data.key}`,
1217
+ `Prefix: ${data.prefix}`,
1218
+ `Label: ${data.label}`,
1219
+ `Team ID: ${teamId}`,
1220
+ `Created: ${data.createdAt}`,
1221
+ "",
1222
+ "IMPORTANT: Save this API key now -- it will not be shown again.",
1223
+ "Usage is billed against the team wallet.",
1224
+ ].join("\n");
1225
+ }
1226
+ catch (err) {
1227
+ return `Error: ${safeError(err)}`;
1228
+ }
1229
+ },
1230
+ };
1231
+ // 16. team_usage
1232
+ const teamUsageTool = {
1233
+ name: "team_usage",
1234
+ description: "Get the team wallet transaction history (funding, usage charges, refunds).",
1235
+ parameters: {
1236
+ team_id: {
1237
+ type: "string",
1238
+ description: "Team ID (UUID)",
1239
+ required: true,
1240
+ },
1241
+ limit: {
1242
+ type: "number",
1243
+ description: "Number of transactions to return (1-100, default 20)",
1244
+ required: false,
1245
+ default: 20,
1246
+ },
1247
+ },
1248
+ execute: async (args) => {
1249
+ try {
1250
+ const teamId = validateUuid(String(args.team_id ?? ""), "team_id");
1251
+ const limit = Math.min(Math.max(Number(args.limit ?? 20), 1), 100);
1252
+ const data = await apiGet(`/api/teams/${encodeURIComponent(teamId)}/wallet/transactions?limit=${limit}`);
1253
+ const txs = data.transactions ?? [];
1254
+ if (txs.length === 0) {
1255
+ return "No transactions found for this team. Use team_fund to add funds.";
1256
+ }
1257
+ const lines = [`Team Transactions (${txs.length})`, ""];
1258
+ for (const tx of txs) {
1259
+ const sign = tx.type === "fund" || tx.type === "refund" ? "+" : "-";
1260
+ lines.push(` ${sign}${formatCents(tx.amountCents)} [${tx.type}] ${tx.description}`);
1261
+ lines.push(` ${tx.createdAt} | Wallet: ${tx.walletId.slice(0, 8)}...`);
1262
+ }
1263
+ return truncate(lines.join("\n"));
1264
+ }
1265
+ catch (err) {
1266
+ return `Error: ${safeError(err)}`;
1267
+ }
1268
+ },
1269
+ };
1270
+ // 17. freeze_agentic_wallet
1271
+ const freezeAgenticWalletTool = {
1272
+ name: "freeze_agentic_wallet",
1273
+ description: "Freeze an agentic wallet to prevent any further spending. The wallet balance is preserved but no transactions are allowed until unfrozen.",
1274
+ parameters: {
1275
+ wallet_id: {
1276
+ type: "string",
1277
+ description: "Agentic wallet ID (UUID)",
1278
+ required: true,
1279
+ },
1280
+ },
1281
+ execute: async (args) => {
1282
+ try {
1283
+ const walletId = validateUuid(String(args.wallet_id ?? ""), "wallet_id");
1284
+ const data = await apiPost(`/api/agent-wallet/${encodeURIComponent(walletId)}/freeze`);
1285
+ return [
1286
+ "Agentic Wallet Frozen",
1287
+ "",
1288
+ `Wallet ID: ${data.id}`,
1289
+ `Label: ${data.label}`,
1290
+ `Balance: ${formatCents(data.balanceCents)}`,
1291
+ `Status: ${data.status}`,
1292
+ "",
1293
+ "The wallet is now frozen. No spending is allowed until unfrozen.",
1294
+ "Use unfreeze_agentic_wallet to re-enable spending.",
1295
+ ].join("\n");
1296
+ }
1297
+ catch (err) {
1298
+ return `Error: ${safeError(err)}`;
1299
+ }
1300
+ },
1301
+ };
1302
+ // 18. unfreeze_agentic_wallet
1303
+ const unfreezeAgenticWalletTool = {
1304
+ name: "unfreeze_agentic_wallet",
1305
+ description: "Unfreeze a previously frozen agentic wallet to re-enable spending.",
1306
+ parameters: {
1307
+ wallet_id: {
1308
+ type: "string",
1309
+ description: "Agentic wallet ID (UUID)",
1310
+ required: true,
1311
+ },
1312
+ },
1313
+ execute: async (args) => {
1314
+ try {
1315
+ const walletId = validateUuid(String(args.wallet_id ?? ""), "wallet_id");
1316
+ const data = await apiPost(`/api/agent-wallet/${encodeURIComponent(walletId)}/unfreeze`);
1317
+ return [
1318
+ "Agentic Wallet Unfrozen",
1319
+ "",
1320
+ `Wallet ID: ${data.id}`,
1321
+ `Label: ${data.label}`,
1322
+ `Balance: ${formatCents(data.balanceCents)}`,
1323
+ `Status: ${data.status}`,
1324
+ "",
1325
+ "The wallet is now active and spending is re-enabled.",
1326
+ ].join("\n");
1327
+ }
1328
+ catch (err) {
1329
+ return `Error: ${safeError(err)}`;
1330
+ }
1331
+ },
1332
+ };
1333
+ // 19. delete_agentic_wallet
1334
+ const deleteAgenticWalletTool = {
1335
+ name: "delete_agentic_wallet",
1336
+ description: "Delete an agentic wallet. Any remaining balance is refunded to your main wallet.",
1337
+ parameters: {
1338
+ wallet_id: {
1339
+ type: "string",
1340
+ description: "Agentic wallet ID (UUID)",
1341
+ required: true,
1342
+ },
1343
+ },
1344
+ execute: async (args) => {
1345
+ try {
1346
+ const walletId = validateUuid(String(args.wallet_id ?? ""), "wallet_id");
1347
+ await apiDelete(`/api/agent-wallet/${encodeURIComponent(walletId)}`);
1348
+ return [
1349
+ "Agentic Wallet Deleted",
1350
+ "",
1351
+ `Wallet ID: ${walletId}`,
1352
+ "",
1353
+ "Any remaining balance has been refunded to your main wallet.",
1354
+ "Use check_balance to verify the refund.",
1355
+ ].join("\n");
1356
+ }
1357
+ catch (err) {
1358
+ return `Error: ${safeError(err)}`;
1359
+ }
1360
+ },
1361
+ };
1362
+ // 20. update_team
1363
+ const updateTeamTool = {
1364
+ name: "update_team",
1365
+ description: "Update a team's settings (name and/or max member count). Only owners and admins can update.",
1366
+ parameters: {
1367
+ team_id: {
1368
+ type: "string",
1369
+ description: "Team ID (UUID)",
1370
+ required: true,
1371
+ },
1372
+ name: {
1373
+ type: "string",
1374
+ description: "New team name (1-100 characters). Optional -- omit to keep current.",
1375
+ required: false,
1376
+ },
1377
+ max_members: {
1378
+ type: "number",
1379
+ description: "New max member count (1-100). Optional -- omit to keep current.",
1380
+ required: false,
1381
+ },
1382
+ },
1383
+ execute: async (args) => {
1384
+ try {
1385
+ const teamId = validateUuid(String(args.team_id ?? ""), "team_id");
1386
+ const body = {};
1387
+ if (args.name !== undefined) {
1388
+ const name = String(args.name).trim();
1389
+ if (!name || name.length === 0 || name.length > 100) {
1390
+ return "Error: name must be 1-100 characters.";
1391
+ }
1392
+ if (/[\x00-\x1f\x7f]/.test(name)) {
1393
+ return "Error: team name contains invalid control characters.";
1394
+ }
1395
+ body.name = name;
1396
+ }
1397
+ if (args.max_members !== undefined) {
1398
+ const maxMembers = Number(args.max_members);
1399
+ if (!Number.isInteger(maxMembers) || maxMembers < 1 || maxMembers > 100) {
1400
+ return "Error: max_members must be an integer between 1 and 100.";
1401
+ }
1402
+ body.maxMembers = maxMembers;
1403
+ }
1404
+ if (Object.keys(body).length === 0) {
1405
+ return "Error: At least one of name or max_members must be provided.";
1406
+ }
1407
+ const data = await apiPatch(`/api/teams/${encodeURIComponent(teamId)}`, body);
1408
+ return [
1409
+ "Team Updated",
1410
+ "",
1411
+ `Team ID: ${data.id}`,
1412
+ `Name: ${data.name}`,
1413
+ `Max Members: ${data.maxMembers ?? "Unlimited"}`,
1414
+ `Status: ${data.status}`,
1415
+ ].join("\n");
1416
+ }
1417
+ catch (err) {
1418
+ return `Error: ${safeError(err)}`;
1419
+ }
1420
+ },
1421
+ };
1422
+ // 21. topup_paypal
1423
+ const topupPaypalTool = {
1424
+ name: "topup_paypal",
1425
+ description: "Top up your DomiNode wallet balance via PayPal. Creates a PayPal order and returns an " +
1426
+ "approval URL to complete payment. Minimum $5 (500 cents), maximum $1,000 (100000 cents).",
1427
+ parameters: {
1428
+ amount_cents: {
1429
+ type: "number",
1430
+ description: "Amount in cents to top up (min 500 = $5, max 100000 = $1,000)",
1431
+ required: true,
1432
+ },
1433
+ },
1434
+ execute: async (args) => {
1435
+ try {
1436
+ const amountCents = Number(args.amount_cents ?? 0);
1437
+ if (!Number.isInteger(amountCents) || amountCents < 500 || amountCents > 100000) {
1438
+ return "Error: amount_cents must be an integer between 500 ($5) and 100000 ($1,000).";
1439
+ }
1440
+ const data = await apiPost("/api/wallet/topup/paypal", { amountCents });
1441
+ return [
1442
+ "PayPal Top-Up Order Created",
1443
+ "",
1444
+ `Order ID: ${data.orderId}`,
1445
+ `Amount: ${formatCents(data.amountCents)}`,
1446
+ `Approval URL: ${data.approvalUrl}`,
1447
+ "",
1448
+ "Open the approval URL in a browser to complete payment.",
1449
+ "Once approved, the funds will be credited to your wallet automatically.",
1450
+ ].join("\n");
1451
+ }
1452
+ catch (err) {
1453
+ return `Error: ${safeError(err)}`;
1454
+ }
1455
+ },
1456
+ };
1457
+ // 22. x402_info
1458
+ const x402InfoTool = {
1459
+ name: "x402_info",
1460
+ description: "Get x402 micropayment protocol information including supported " +
1461
+ "facilitators, pricing, and payment options.",
1462
+ parameters: {},
1463
+ execute: async () => {
1464
+ try {
1465
+ const data = await apiGet("/api/x402/info");
1466
+ return JSON.stringify(data, null, 2);
1467
+ }
1468
+ catch (err) {
1469
+ return `Error: ${safeError(err)}`;
1470
+ }
1471
+ },
1472
+ };
1473
+ // 23. update_team_member_role
1474
+ const updateTeamMemberRoleTool = {
1475
+ name: "update_team_member_role",
1476
+ description: "Update a team member's role (member or admin). Only owners and admins can change roles.",
1477
+ parameters: {
1478
+ team_id: {
1479
+ type: "string",
1480
+ description: "Team ID (UUID)",
1481
+ required: true,
1482
+ },
1483
+ user_id: {
1484
+ type: "string",
1485
+ description: "User ID (UUID) of the member whose role to change",
1486
+ required: true,
1487
+ },
1488
+ role: {
1489
+ type: "string",
1490
+ description: "New role for the member",
1491
+ required: true,
1492
+ enum: ["member", "admin"],
1493
+ },
1494
+ },
1495
+ execute: async (args) => {
1496
+ try {
1497
+ const teamId = validateUuid(String(args.team_id ?? ""), "team_id");
1498
+ const userId = validateUuid(String(args.user_id ?? ""), "user_id");
1499
+ const role = String(args.role ?? "").trim();
1500
+ if (role !== "member" && role !== "admin") {
1501
+ return "Error: role must be 'member' or 'admin'.";
1502
+ }
1503
+ const data = await apiPatch(`/api/teams/${encodeURIComponent(teamId)}/members/${encodeURIComponent(userId)}`, {
1504
+ role,
1505
+ });
1506
+ return [
1507
+ "Team Member Role Updated",
1508
+ "",
1509
+ `Team ID: ${data.teamId ?? teamId}`,
1510
+ `User ID: ${data.userId ?? userId}`,
1511
+ `New Role: ${data.role ?? role}`,
1512
+ ].join("\n");
1513
+ }
1514
+ catch (err) {
1515
+ return `Error: ${safeError(err)}`;
1516
+ }
1517
+ },
1518
+ };
1519
+ // 24. update_wallet_policy
1520
+ const updateWalletPolicyTool = {
1521
+ name: "update_wallet_policy",
1522
+ description: "Update the policy of an agentic wallet. Sets or clears the daily budget cap " +
1523
+ "and/or domain allowlist. Pass null to clear a field. At least one field must be provided.",
1524
+ parameters: {
1525
+ wallet_id: {
1526
+ type: "string",
1527
+ description: "Agentic wallet ID (UUID)",
1528
+ required: true,
1529
+ },
1530
+ daily_limit_cents: {
1531
+ type: "number",
1532
+ description: "Daily spending budget in cents (1-1000000), or null to clear",
1533
+ required: false,
1534
+ },
1535
+ allowed_domains: {
1536
+ type: "array",
1537
+ description: "Domain allowlist (max 100 entries, each <= 253 chars), or null to clear",
1538
+ required: false,
1539
+ },
1540
+ },
1541
+ execute: async (args) => {
1542
+ try {
1543
+ const walletId = validateUuid(String(args.wallet_id ?? ""), "wallet_id");
1544
+ const body = {};
1545
+ if (args.daily_limit_cents !== undefined) {
1546
+ if (args.daily_limit_cents === null) {
1547
+ body.dailyLimitCents = null;
1548
+ }
1549
+ else {
1550
+ const dailyLimit = Number(args.daily_limit_cents);
1551
+ if (!Number.isInteger(dailyLimit) || dailyLimit < 1 || dailyLimit > 1000000) {
1552
+ return "Error: daily_limit_cents must be a positive integer between 1 and 1000000, or null to clear.";
1553
+ }
1554
+ body.dailyLimitCents = dailyLimit;
1555
+ }
1556
+ }
1557
+ if (args.allowed_domains !== undefined) {
1558
+ if (args.allowed_domains === null) {
1559
+ body.allowedDomains = null;
1560
+ }
1561
+ else {
1562
+ if (!Array.isArray(args.allowed_domains)) {
1563
+ return "Error: allowed_domains must be an array of domain strings, or null to clear.";
1564
+ }
1565
+ if (args.allowed_domains.length > 100) {
1566
+ return "Error: allowed_domains must have at most 100 entries.";
1567
+ }
1568
+ for (let i = 0; i < args.allowed_domains.length; i++) {
1569
+ const d = args.allowed_domains[i];
1570
+ if (typeof d !== "string") {
1571
+ return `Error: allowed_domains[${i}] must be a string.`;
1572
+ }
1573
+ if (d.length > 253) {
1574
+ return `Error: allowed_domains[${i}] exceeds 253 characters.`;
1575
+ }
1576
+ if (!DOMAIN_RE.test(d)) {
1577
+ return `Error: allowed_domains[${i}] is not a valid domain: ${d}`;
1578
+ }
1579
+ }
1580
+ body.allowedDomains = args.allowed_domains;
1581
+ }
1582
+ }
1583
+ if (Object.keys(body).length === 0) {
1584
+ return "Error: At least one of daily_limit_cents or allowed_domains must be provided.";
1585
+ }
1586
+ const data = await apiPatch(`/api/agent-wallet/${encodeURIComponent(walletId)}/policy`, body);
1587
+ return [
1588
+ "Wallet Policy Updated",
1589
+ "",
1590
+ `Wallet ID: ${data.id}`,
1591
+ `Daily Limit: ${data.dailyLimitCents != null ? formatCents(data.dailyLimitCents) + "/day" : "None (unlimited)"}`,
1592
+ `Allowed Domains: ${data.allowedDomains != null && data.allowedDomains.length > 0 ? data.allowedDomains.join(", ") : "None (all domains)"}`,
1593
+ ].join("\n");
1594
+ }
1595
+ catch (err) {
1596
+ return `Error: ${safeError(err)}`;
1597
+ }
1598
+ },
1599
+ };
1600
+ // ---------------------------------------------------------------------------
1601
+ // Plugin export — the tools array that OpenClaw discovers
1602
+ // ---------------------------------------------------------------------------
1603
+ export const tools = [
1604
+ proxiedFetchTool,
1605
+ checkBalanceTool,
1606
+ checkUsageTool,
1607
+ getProxyConfigTool,
1608
+ listSessionsTool,
1609
+ createAgenticWalletTool,
1610
+ fundAgenticWalletTool,
1611
+ checkAgenticBalanceTool,
1612
+ listAgenticWalletsTool,
1613
+ agenticTransactionsTool,
1614
+ freezeAgenticWalletTool,
1615
+ unfreezeAgenticWalletTool,
1616
+ deleteAgenticWalletTool,
1617
+ createTeamTool,
1618
+ listTeamsTool,
1619
+ teamDetailsTool,
1620
+ teamFundTool,
1621
+ teamCreateKeyTool,
1622
+ teamUsageTool,
1623
+ updateTeamTool,
1624
+ updateTeamMemberRoleTool,
1625
+ topupPaypalTool,
1626
+ x402InfoTool,
1627
+ updateWalletPolicyTool,
1628
+ ];
1629
+ /**
1630
+ * Plugin metadata for OpenClaw discovery.
1631
+ */
1632
+ export const plugin = {
1633
+ name: "dominusnode-proxy",
1634
+ version: "1.0.0",
1635
+ description: "Route web requests through DomiNode's rotating proxy network with geo-targeting, " +
1636
+ "balance management, agentic wallets, and team collaboration.",
1637
+ tools,
1638
+ };
1639
+ export default plugin;
1640
+ // Test exports — used by plugin.test.ts
1641
+ export { isPrivateIp, validateTargetUrl, validateCountry, validateUuid, normalizeIpv4, stripDangerousKeys, scrubCredentials, truncate, formatBytes, formatCents, };