arc402-cli 0.6.0 → 0.7.1
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/commands/backup.d.ts +3 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +106 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +11 -1
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/discover.d.ts.map +1 -1
- package/dist/commands/discover.js +60 -15
- package/dist/commands/discover.js.map +1 -1
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +205 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/wallet.d.ts.map +1 -1
- package/dist/commands/wallet.js +192 -58
- package/dist/commands/wallet.js.map +1 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +146 -9
- package/dist/commands/watch.js.map +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +35 -3
- package/dist/config.js.map +1 -1
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +359 -220
- package/dist/daemon/index.js.map +1 -1
- package/dist/endpoint-notify.d.ts +9 -1
- package/dist/endpoint-notify.d.ts.map +1 -1
- package/dist/endpoint-notify.js +116 -3
- package/dist/endpoint-notify.js.map +1 -1
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +4 -0
- package/dist/program.js.map +1 -1
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +45 -34
- package/dist/repl.js.map +1 -1
- package/dist/ui/format.d.ts.map +1 -1
- package/dist/ui/format.js +2 -0
- package/dist/ui/format.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/backup.ts +117 -0
- package/src/commands/config.ts +12 -2
- package/src/commands/discover.ts +74 -21
- package/src/commands/doctor.ts +172 -0
- package/src/commands/wallet.ts +194 -57
- package/src/commands/watch.ts +207 -10
- package/src/config.ts +48 -2
- package/src/daemon/index.ts +297 -152
- package/src/endpoint-notify.ts +86 -3
- package/src/index.ts +26 -0
- package/src/program.ts +4 -0
- package/src/repl.ts +53 -42
- package/src/ui/format.ts +1 -0
package/src/endpoint-notify.ts
CHANGED
|
@@ -5,9 +5,75 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { ethers } from "ethers";
|
|
7
7
|
import { AGENT_REGISTRY_ABI } from "./abis";
|
|
8
|
+
import * as dns from "dns/promises";
|
|
8
9
|
|
|
9
10
|
export const DEFAULT_REGISTRY_ADDRESS = "0xD5c2851B00090c92Ba7F4723FB548bb30C9B6865";
|
|
10
11
|
|
|
12
|
+
// ─── SSRF protection ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const RFC1918_RANGES = [
|
|
15
|
+
// 10.0.0.0/8
|
|
16
|
+
(n: number) => (n >>> 24) === 10,
|
|
17
|
+
// 172.16.0.0/12
|
|
18
|
+
(n: number) => (n >>> 24) === 172 && ((n >>> 16) & 0xff) >= 16 && ((n >>> 16) & 0xff) <= 31,
|
|
19
|
+
// 192.168.0.0/16
|
|
20
|
+
(n: number) => (n >>> 24) === 192 && ((n >>> 16) & 0xff) === 168,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function ipToInt(ip: string): number {
|
|
24
|
+
return ip.split(".").reduce((acc, octet) => (acc << 8) | parseInt(octet, 10), 0) >>> 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isPrivateIp(ip: string): boolean {
|
|
28
|
+
// IPv6 non-loopback — block (only allow ::1)
|
|
29
|
+
if (ip.includes(":")) {
|
|
30
|
+
return ip !== "::1";
|
|
31
|
+
}
|
|
32
|
+
const n = ipToInt(ip);
|
|
33
|
+
// Loopback 127.0.0.0/8
|
|
34
|
+
if ((n >>> 24) === 127) return true;
|
|
35
|
+
// Link-local 169.254.0.0/16 (includes AWS metadata 169.254.169.254)
|
|
36
|
+
if ((n >>> 24) === 169 && ((n >>> 16) & 0xff) === 254) return true;
|
|
37
|
+
return RFC1918_RANGES.some((fn) => fn(n));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validates that an endpoint URL is safe to connect to (SSRF protection).
|
|
42
|
+
* Allows HTTPS (any host) and HTTP only for localhost/127.0.0.1.
|
|
43
|
+
* Blocks RFC 1918, link-local, loopback non-localhost, and AWS metadata IPs.
|
|
44
|
+
*/
|
|
45
|
+
export async function validateEndpointUrl(endpoint: string): Promise<void> {
|
|
46
|
+
let parsed: URL;
|
|
47
|
+
try {
|
|
48
|
+
parsed = new URL(endpoint);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`Invalid endpoint URL: ${endpoint}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { protocol, hostname } = parsed;
|
|
54
|
+
const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
55
|
+
|
|
56
|
+
if (protocol !== "https:" && !(protocol === "http:" && isLocalhost)) {
|
|
57
|
+
throw new Error(`Endpoint must use HTTPS (or HTTP for localhost). Got: ${endpoint}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Resolve hostname and check resolved IPs
|
|
61
|
+
if (!isLocalhost) {
|
|
62
|
+
let addresses: string[];
|
|
63
|
+
try {
|
|
64
|
+
addresses = await dns.resolve(hostname);
|
|
65
|
+
} catch {
|
|
66
|
+
// If DNS fails, let the fetch fail naturally; don't block
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
for (const addr of addresses) {
|
|
70
|
+
if (isPrivateIp(addr)) {
|
|
71
|
+
throw new Error(`Endpoint resolves to a private/reserved IP (${addr}) — blocked for security`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
11
77
|
/**
|
|
12
78
|
* Reads an agent's public HTTP endpoint from AgentRegistry.
|
|
13
79
|
* Returns empty string if not registered or no endpoint.
|
|
@@ -25,18 +91,35 @@ export async function resolveAgentEndpoint(
|
|
|
25
91
|
/**
|
|
26
92
|
* POSTs JSON payload to {endpoint}{path}. Returns true on success.
|
|
27
93
|
* Never throws — logs a warning on failure.
|
|
94
|
+
* Validates endpoint URL for SSRF before connecting.
|
|
95
|
+
* If signingKey is provided, signs the payload and adds X-ARC402-Signature / X-ARC402-Signer headers.
|
|
28
96
|
*/
|
|
29
97
|
export async function notifyAgent(
|
|
30
98
|
endpoint: string,
|
|
31
99
|
path: string,
|
|
32
|
-
payload: Record<string, unknown
|
|
100
|
+
payload: Record<string, unknown>,
|
|
101
|
+
signingKey?: string
|
|
33
102
|
): Promise<boolean> {
|
|
34
103
|
if (!endpoint) return false;
|
|
35
104
|
try {
|
|
105
|
+
await validateEndpointUrl(endpoint);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn(`Warning: endpoint notify blocked (${endpoint}${path}): ${err instanceof Error ? err.message : String(err)}`);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const body = JSON.stringify(payload);
|
|
112
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
113
|
+
if (signingKey) {
|
|
114
|
+
const wallet = new ethers.Wallet(signingKey);
|
|
115
|
+
const signature = await wallet.signMessage(body);
|
|
116
|
+
headers["X-ARC402-Signature"] = signature;
|
|
117
|
+
headers["X-ARC402-Signer"] = wallet.address;
|
|
118
|
+
}
|
|
36
119
|
const res = await fetch(`${endpoint}${path}`, {
|
|
37
120
|
method: "POST",
|
|
38
|
-
headers
|
|
39
|
-
body
|
|
121
|
+
headers,
|
|
122
|
+
body,
|
|
40
123
|
});
|
|
41
124
|
return res.ok;
|
|
42
125
|
} catch (err) {
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createProgram } from "./program";
|
|
3
3
|
import { startREPL } from "./repl";
|
|
4
|
+
import { configExists, loadConfig, saveConfig } from "./config";
|
|
5
|
+
|
|
6
|
+
// ── Upgrade safety check ────────────────────────────────────────────────────
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
8
|
+
const currentVersion: string = (require("../package.json") as { version: string }).version;
|
|
9
|
+
|
|
10
|
+
function checkUpgrade(): void {
|
|
11
|
+
if (!configExists()) return;
|
|
12
|
+
try {
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
const prev = config.lastCliVersion;
|
|
15
|
+
if (prev && prev !== currentVersion) {
|
|
16
|
+
// Compare semver loosely — just print if different
|
|
17
|
+
console.log(`◈ Upgraded from ${prev} → ${currentVersion}`);
|
|
18
|
+
}
|
|
19
|
+
if (config.lastCliVersion !== currentVersion) {
|
|
20
|
+
// Never overwrite existing fields — only update lastCliVersion
|
|
21
|
+
saveConfig({ ...config, lastCliVersion: currentVersion });
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// Never crash on upgrade check
|
|
25
|
+
}
|
|
26
|
+
}
|
|
4
27
|
|
|
5
28
|
const printMode = process.argv.includes("--print");
|
|
6
29
|
|
|
@@ -11,6 +34,7 @@ if (printMode) {
|
|
|
11
34
|
process.env["NO_COLOR"] = "1";
|
|
12
35
|
process.env["FORCE_COLOR"] = "0";
|
|
13
36
|
process.env["ARC402_PRINT"] = "1";
|
|
37
|
+
checkUpgrade();
|
|
14
38
|
const program = createProgram();
|
|
15
39
|
void program.parseAsync(process.argv).then(() => process.exit(0)).catch((e: unknown) => {
|
|
16
40
|
console.error(e instanceof Error ? e.message : String(e));
|
|
@@ -18,9 +42,11 @@ if (printMode) {
|
|
|
18
42
|
});
|
|
19
43
|
} else if (process.argv.length <= 2) {
|
|
20
44
|
// No subcommand — enter interactive REPL
|
|
45
|
+
checkUpgrade();
|
|
21
46
|
void startREPL();
|
|
22
47
|
} else {
|
|
23
48
|
// One-shot mode — arc402 wallet deploy still works as usual
|
|
49
|
+
checkUpgrade();
|
|
24
50
|
const program = createProgram();
|
|
25
51
|
program.parse(process.argv);
|
|
26
52
|
}
|
package/src/program.ts
CHANGED
|
@@ -27,10 +27,12 @@ import { registerVerifyCommand } from "./commands/verify";
|
|
|
27
27
|
import { registerContractInteractionCommands } from "./commands/contract-interaction";
|
|
28
28
|
import { registerWatchtowerCommands } from "./commands/watchtower";
|
|
29
29
|
import { registerColdStartCommands } from "./commands/coldstart";
|
|
30
|
+
import { registerDoctorCommand } from "./commands/doctor";
|
|
30
31
|
import { registerMigrateCommands } from "./commands/migrate";
|
|
31
32
|
import { registerFeedCommand } from "./commands/feed";
|
|
32
33
|
import { registerArenaCommands } from "./commands/arena";
|
|
33
34
|
import { registerWatchCommand } from "./commands/watch";
|
|
35
|
+
import { registerBackupCommand } from "./commands/backup";
|
|
34
36
|
import reputation from "./commands/reputation.js";
|
|
35
37
|
import policy from "./commands/policy.js";
|
|
36
38
|
|
|
@@ -72,10 +74,12 @@ export function createProgram(): Command {
|
|
|
72
74
|
registerContractInteractionCommands(program);
|
|
73
75
|
registerWatchtowerCommands(program);
|
|
74
76
|
registerColdStartCommands(program);
|
|
77
|
+
registerDoctorCommand(program);
|
|
75
78
|
registerMigrateCommands(program);
|
|
76
79
|
registerFeedCommand(program);
|
|
77
80
|
registerArenaCommands(program);
|
|
78
81
|
registerWatchCommand(program);
|
|
82
|
+
registerBackupCommand(program);
|
|
79
83
|
program.addCommand(reputation);
|
|
80
84
|
program.addCommand(policy);
|
|
81
85
|
|
package/src/repl.ts
CHANGED
|
@@ -6,17 +6,11 @@ import { createProgram } from "./program";
|
|
|
6
6
|
import { getBannerLines, BannerConfig } from "./ui/banner";
|
|
7
7
|
import { c } from "./ui/colors";
|
|
8
8
|
|
|
9
|
-
// ─── Sentinel to intercept process.exit() from commands ──────────────────────
|
|
10
|
-
|
|
11
|
-
class REPLExitSignal extends Error {
|
|
12
|
-
constructor(public readonly code: number = 0) {
|
|
13
|
-
super("repl-exit-signal");
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
9
|
// ─── Config / banner helpers ──────────────────────────────────────────────────
|
|
18
10
|
|
|
19
11
|
const CONFIG_PATH = path.join(os.homedir(), ".arc402", "config.json");
|
|
12
|
+
const HISTORY_PATH = path.join(os.homedir(), ".arc402", "repl_history");
|
|
13
|
+
const MAX_HISTORY = 1000;
|
|
20
14
|
|
|
21
15
|
async function loadBannerConfig(): Promise<BannerConfig | undefined> {
|
|
22
16
|
if (!fs.existsSync(CONFIG_PATH)) return undefined;
|
|
@@ -99,10 +93,21 @@ function parseTokens(input: string): string[] {
|
|
|
99
93
|
let current = "";
|
|
100
94
|
let inQuote = false;
|
|
101
95
|
let quoteChar = "";
|
|
96
|
+
let escape = false;
|
|
102
97
|
for (const ch of input) {
|
|
98
|
+
if (escape) {
|
|
99
|
+
current += ch;
|
|
100
|
+
escape = false;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
103
|
if (inQuote) {
|
|
104
|
-
if (ch ===
|
|
105
|
-
|
|
104
|
+
if (quoteChar === '"' && ch === "\\") {
|
|
105
|
+
escape = true;
|
|
106
|
+
} else if (ch === quoteChar) {
|
|
107
|
+
inQuote = false;
|
|
108
|
+
} else {
|
|
109
|
+
current += ch;
|
|
110
|
+
}
|
|
106
111
|
} else if (ch === '"' || ch === "'") {
|
|
107
112
|
inQuote = true;
|
|
108
113
|
quoteChar = ch;
|
|
@@ -174,6 +179,14 @@ class TUI {
|
|
|
174
179
|
async start(): Promise<void> {
|
|
175
180
|
this.bannerCfg = await loadBannerConfig();
|
|
176
181
|
|
|
182
|
+
// Load persisted history
|
|
183
|
+
try {
|
|
184
|
+
if (fs.existsSync(HISTORY_PATH)) {
|
|
185
|
+
const lines = fs.readFileSync(HISTORY_PATH, "utf-8").split("\n").filter(Boolean);
|
|
186
|
+
this.history = lines.slice(-MAX_HISTORY);
|
|
187
|
+
}
|
|
188
|
+
} catch { /* non-fatal */ }
|
|
189
|
+
|
|
177
190
|
// Build command metadata for completion
|
|
178
191
|
const template = createProgram();
|
|
179
192
|
this.topCmds = template.commands.map((cmd) => cmd.name());
|
|
@@ -529,42 +542,32 @@ class TUI {
|
|
|
529
542
|
writeErr: (str) => process.stderr.write(str),
|
|
530
543
|
});
|
|
531
544
|
|
|
532
|
-
const origExit = process.exit;
|
|
533
|
-
(process as NodeJS.Process).exit = ((code?: number) => {
|
|
534
|
-
throw new REPLExitSignal(code ?? 0);
|
|
535
|
-
}) as typeof process.exit;
|
|
536
|
-
|
|
537
545
|
try {
|
|
538
546
|
await prog.parseAsync(["node", "arc402", ...tokens]);
|
|
539
547
|
} catch (err) {
|
|
540
|
-
|
|
541
|
-
|
|
548
|
+
const e = err as { code?: string; message?: string };
|
|
549
|
+
if (
|
|
550
|
+
e.code === "commander.helpDisplayed" ||
|
|
551
|
+
e.code === "commander.version" ||
|
|
552
|
+
e.code === "commander.executeSubCommandAsync"
|
|
553
|
+
) {
|
|
554
|
+
// already written or normal exit
|
|
555
|
+
} else if (e.code === "commander.unknownCommand") {
|
|
556
|
+
process.stdout.write(
|
|
557
|
+
`\n ${c.failure} ${chalk.red(`Unknown command: ${chalk.white(tokens[0])}`)} \n`
|
|
558
|
+
);
|
|
559
|
+
process.stdout.write(
|
|
560
|
+
chalk.dim(" Type 'help' for available commands\n")
|
|
561
|
+
);
|
|
562
|
+
} else if (e.code?.startsWith("commander.")) {
|
|
563
|
+
process.stdout.write(
|
|
564
|
+
`\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`
|
|
565
|
+
);
|
|
542
566
|
} else {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
e.code === "commander.version"
|
|
547
|
-
) {
|
|
548
|
-
// already written
|
|
549
|
-
} else if (e.code === "commander.unknownCommand") {
|
|
550
|
-
process.stdout.write(
|
|
551
|
-
`\n ${c.failure} ${chalk.red(`Unknown command: ${chalk.white(tokens[0])}`)} \n`
|
|
552
|
-
);
|
|
553
|
-
process.stdout.write(
|
|
554
|
-
chalk.dim(" Type 'help' for available commands\n")
|
|
555
|
-
);
|
|
556
|
-
} else if (e.code?.startsWith("commander.")) {
|
|
557
|
-
process.stdout.write(
|
|
558
|
-
`\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`
|
|
559
|
-
);
|
|
560
|
-
} else {
|
|
561
|
-
process.stdout.write(
|
|
562
|
-
`\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`
|
|
563
|
-
);
|
|
564
|
-
}
|
|
567
|
+
process.stdout.write(
|
|
568
|
+
`\n ${c.failure} ${chalk.red(e.message ?? String(err))}\n`
|
|
569
|
+
);
|
|
565
570
|
}
|
|
566
|
-
} finally {
|
|
567
|
-
(process as NodeJS.Process).exit = origExit;
|
|
568
571
|
}
|
|
569
572
|
|
|
570
573
|
// Restore raw mode + our listener (interactive commands may have toggled it)
|
|
@@ -579,7 +582,8 @@ class TUI {
|
|
|
579
582
|
|
|
580
583
|
// ── OpenClaw chat ─────────────────────────────────────────────────────────────
|
|
581
584
|
|
|
582
|
-
private async sendChat(
|
|
585
|
+
private async sendChat(rawMessage: string): Promise<void> {
|
|
586
|
+
const message = rawMessage.trim().slice(0, 10000);
|
|
583
587
|
write(ansi.move(this.scrollBot, 1));
|
|
584
588
|
|
|
585
589
|
let res: Response;
|
|
@@ -762,6 +766,13 @@ class TUI {
|
|
|
762
766
|
// ── Exit ──────────────────────────────────────────────────────────────────────
|
|
763
767
|
|
|
764
768
|
private exitGracefully(): void {
|
|
769
|
+
// Save history
|
|
770
|
+
try {
|
|
771
|
+
const toSave = this.history.slice(-MAX_HISTORY);
|
|
772
|
+
fs.mkdirSync(path.dirname(HISTORY_PATH), { recursive: true });
|
|
773
|
+
fs.writeFileSync(HISTORY_PATH, toSave.join("\n") + "\n", { mode: 0o600 });
|
|
774
|
+
} catch { /* non-fatal */ }
|
|
775
|
+
|
|
765
776
|
write(ansi.move(this.inputRow, 1) + ansi.clearLine);
|
|
766
777
|
write(" " + chalk.cyanBright("◈") + chalk.dim(" goodbye") + "\n");
|
|
767
778
|
write(ansi.resetScroll);
|
package/src/ui/format.ts
CHANGED
|
@@ -42,6 +42,7 @@ export function formatTimeAgo(timestampSeconds: number): string {
|
|
|
42
42
|
const now = Math.floor(Date.now() / 1000);
|
|
43
43
|
const delta = now - timestampSeconds;
|
|
44
44
|
|
|
45
|
+
if (delta < 0) return "in " + formatDuration(Math.abs(delta));
|
|
45
46
|
if (delta < 60) return "just now";
|
|
46
47
|
if (delta < 3600) {
|
|
47
48
|
const m = Math.floor(delta / 60);
|