arc402-cli 0.5.0 → 0.7.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/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +17 -1
- package/dist/commands/config.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 +467 -23
- package/dist/commands/wallet.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +11 -2
- package/dist/config.js.map +1 -1
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +294 -208
- package/dist/daemon/index.js.map +1 -1
- package/dist/endpoint-notify.d.ts +7 -0
- package/dist/endpoint-notify.d.ts.map +1 -1
- package/dist/endpoint-notify.js +104 -0
- package/dist/endpoint-notify.js.map +1 -1
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +2 -0
- package/dist/program.js.map +1 -1
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +565 -162
- package/dist/repl.js.map +1 -1
- package/dist/ui/banner.d.ts +2 -0
- package/dist/ui/banner.d.ts.map +1 -1
- package/dist/ui/banner.js +27 -18
- package/dist/ui/banner.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/dist/ui/spinner.d.ts.map +1 -1
- package/dist/ui/spinner.js +11 -0
- package/dist/ui/spinner.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/config.ts +18 -2
- package/src/commands/doctor.ts +172 -0
- package/src/commands/wallet.ts +512 -35
- package/src/config.ts +10 -1
- package/src/daemon/index.ts +234 -140
- package/src/endpoint-notify.ts +73 -0
- package/src/index.ts +15 -1
- package/src/program.ts +2 -0
- package/src/repl.ts +673 -197
- package/src/ui/banner.ts +26 -19
- package/src/ui/format.ts +1 -0
- package/src/ui/spinner.ts +10 -0
package/src/daemon/index.ts
CHANGED
|
@@ -14,6 +14,8 @@ import * as http from "http";
|
|
|
14
14
|
import { ethers } from "ethers";
|
|
15
15
|
import Database from "better-sqlite3";
|
|
16
16
|
|
|
17
|
+
import * as crypto from "crypto";
|
|
18
|
+
|
|
17
19
|
import {
|
|
18
20
|
loadDaemonConfig,
|
|
19
21
|
loadMachineKey,
|
|
@@ -147,6 +149,49 @@ function openStateDB(dbPath: string): DaemonDB {
|
|
|
147
149
|
};
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
// ─── Auth token ───────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
const DAEMON_TOKEN_FILE = path.join(path.dirname(DAEMON_SOCK), "daemon.token");
|
|
155
|
+
|
|
156
|
+
function generateApiToken(): string {
|
|
157
|
+
return crypto.randomBytes(32).toString("hex");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function saveApiToken(token: string): void {
|
|
161
|
+
fs.mkdirSync(path.dirname(DAEMON_TOKEN_FILE), { recursive: true, mode: 0o700 });
|
|
162
|
+
fs.writeFileSync(DAEMON_TOKEN_FILE, token, { mode: 0o600 });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function loadApiToken(): string | null {
|
|
166
|
+
try {
|
|
167
|
+
return fs.readFileSync(DAEMON_TOKEN_FILE, "utf-8").trim();
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Rate limiter ─────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
interface RateBucket { count: number; resetTime: number }
|
|
176
|
+
const rateLimitMap = new Map<string, RateBucket>();
|
|
177
|
+
const RATE_LIMIT = 30;
|
|
178
|
+
const RATE_WINDOW_MS = 60_000;
|
|
179
|
+
|
|
180
|
+
function checkRateLimit(ip: string): boolean {
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
let bucket = rateLimitMap.get(ip);
|
|
183
|
+
if (!bucket || now >= bucket.resetTime) {
|
|
184
|
+
bucket = { count: 0, resetTime: now + RATE_WINDOW_MS };
|
|
185
|
+
rateLimitMap.set(ip, bucket);
|
|
186
|
+
}
|
|
187
|
+
bucket.count++;
|
|
188
|
+
return bucket.count <= RATE_LIMIT;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Body size limit ──────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
|
|
194
|
+
|
|
150
195
|
// ─── Logger ───────────────────────────────────────────────────────────────────
|
|
151
196
|
|
|
152
197
|
function openLogger(logPath: string, foreground: boolean): (entry: Record<string, unknown>) => void {
|
|
@@ -181,7 +226,7 @@ interface IpcContext {
|
|
|
181
226
|
bundlerEndpoint: string;
|
|
182
227
|
}
|
|
183
228
|
|
|
184
|
-
function startIpcServer(ctx: IpcContext, log: ReturnType<typeof openLogger
|
|
229
|
+
function startIpcServer(ctx: IpcContext, log: ReturnType<typeof openLogger>, apiToken: string): net.Server {
|
|
185
230
|
// Remove stale socket
|
|
186
231
|
if (fs.existsSync(DAEMON_SOCK)) {
|
|
187
232
|
fs.unlinkSync(DAEMON_SOCK);
|
|
@@ -189,20 +234,34 @@ function startIpcServer(ctx: IpcContext, log: ReturnType<typeof openLogger>): ne
|
|
|
189
234
|
|
|
190
235
|
const server = net.createServer((socket) => {
|
|
191
236
|
let buf = "";
|
|
237
|
+
let authenticated = false;
|
|
192
238
|
socket.on("data", (data) => {
|
|
193
239
|
buf += data.toString();
|
|
194
240
|
const lines = buf.split("\n");
|
|
195
241
|
buf = lines.pop() ?? "";
|
|
196
242
|
for (const line of lines) {
|
|
197
243
|
if (!line.trim()) continue;
|
|
198
|
-
let cmd: { command: string; id?: string; reason?: string };
|
|
244
|
+
let cmd: { command: string; id?: string; reason?: string; auth?: string };
|
|
199
245
|
try {
|
|
200
|
-
cmd = JSON.parse(line) as { command: string; id?: string; reason?: string };
|
|
246
|
+
cmd = JSON.parse(line) as { command: string; id?: string; reason?: string; auth?: string };
|
|
201
247
|
} catch {
|
|
202
248
|
socket.write(JSON.stringify({ ok: false, error: "invalid_json" }) + "\n");
|
|
203
249
|
continue;
|
|
204
250
|
}
|
|
205
251
|
|
|
252
|
+
// First message must be auth
|
|
253
|
+
if (!authenticated) {
|
|
254
|
+
if (cmd.auth === apiToken) {
|
|
255
|
+
authenticated = true;
|
|
256
|
+
socket.write(JSON.stringify({ ok: true, authenticated: true }) + "\n");
|
|
257
|
+
} else {
|
|
258
|
+
log({ event: "ipc_auth_failed" });
|
|
259
|
+
socket.write(JSON.stringify({ ok: false, error: "unauthorized" }) + "\n");
|
|
260
|
+
socket.destroy();
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
206
265
|
const response = handleIpcCommand(cmd, ctx, log);
|
|
207
266
|
socket.write(JSON.stringify(response) + "\n");
|
|
208
267
|
}
|
|
@@ -547,12 +606,43 @@ export async function runDaemon(foreground = false): Promise<void> {
|
|
|
547
606
|
log({ event: "pid_written", pid: process.pid, path: DAEMON_PID });
|
|
548
607
|
}
|
|
549
608
|
|
|
609
|
+
// ── Generate and save API token ──────────────────────────────────────────
|
|
610
|
+
const apiToken = generateApiToken();
|
|
611
|
+
saveApiToken(apiToken);
|
|
612
|
+
log({ event: "auth_token_saved", path: DAEMON_TOKEN_FILE });
|
|
613
|
+
|
|
550
614
|
// ── Start IPC socket ─────────────────────────────────────────────────────
|
|
551
|
-
const ipcServer = startIpcServer(ipcCtx, log);
|
|
615
|
+
const ipcServer = startIpcServer(ipcCtx, log, apiToken);
|
|
552
616
|
|
|
553
617
|
// ── Start HTTP relay server (public endpoint) ────────────────────────────
|
|
554
618
|
const httpPort = config.relay.listen_port ?? 4402;
|
|
555
619
|
|
|
620
|
+
/**
|
|
621
|
+
* Read request body with a size cap. Destroys the request and sends 413
|
|
622
|
+
* if the body exceeds MAX_BODY_SIZE. Returns null in that case.
|
|
623
|
+
*/
|
|
624
|
+
function readBody(req: http.IncomingMessage, res: http.ServerResponse): Promise<string | null> {
|
|
625
|
+
return new Promise((resolve) => {
|
|
626
|
+
let body = "";
|
|
627
|
+
let size = 0;
|
|
628
|
+
req.on("data", (chunk: Buffer) => {
|
|
629
|
+
size += chunk.length;
|
|
630
|
+
if (size > MAX_BODY_SIZE) {
|
|
631
|
+
req.destroy();
|
|
632
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
633
|
+
res.end(JSON.stringify({ error: "payload_too_large" }));
|
|
634
|
+
resolve(null);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
body += chunk.toString();
|
|
638
|
+
});
|
|
639
|
+
req.on("end", () => { resolve(body); });
|
|
640
|
+
req.on("error", () => { resolve(null); });
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const PUBLIC_GET_PATHS = new Set(["/", "/health", "/agent", "/capabilities", "/status"]);
|
|
645
|
+
|
|
556
646
|
const httpServer = http.createServer(async (req, res) => {
|
|
557
647
|
// CORS headers
|
|
558
648
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
@@ -563,6 +653,27 @@ export async function runDaemon(foreground = false): Promise<void> {
|
|
|
563
653
|
const url = new URL(req.url || "/", `http://localhost:${httpPort}`);
|
|
564
654
|
const pathname = url.pathname;
|
|
565
655
|
|
|
656
|
+
// Rate limiting (all endpoints)
|
|
657
|
+
const clientIp = (req.socket.remoteAddress ?? "unknown").replace(/^::ffff:/, "");
|
|
658
|
+
if (!checkRateLimit(clientIp)) {
|
|
659
|
+
log({ event: "rate_limited", ip: clientIp, path: pathname });
|
|
660
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
661
|
+
res.end(JSON.stringify({ error: "too_many_requests" }));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Auth required on all POST endpoints (GET public paths are open)
|
|
666
|
+
if (req.method === "POST" || (req.method === "GET" && !PUBLIC_GET_PATHS.has(pathname))) {
|
|
667
|
+
const authHeader = req.headers["authorization"] ?? "";
|
|
668
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
669
|
+
if (token !== apiToken) {
|
|
670
|
+
log({ event: "http_unauthorized", ip: clientIp, path: pathname });
|
|
671
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
672
|
+
res.end(JSON.stringify({ error: "unauthorized" }));
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
566
677
|
// Health / info
|
|
567
678
|
if (pathname === "/" || pathname === "/health") {
|
|
568
679
|
const info = {
|
|
@@ -594,10 +705,9 @@ export async function runDaemon(foreground = false): Promise<void> {
|
|
|
594
705
|
|
|
595
706
|
// Receive hire proposal
|
|
596
707
|
if (pathname === "/hire" && req.method === "POST") {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
try {
|
|
708
|
+
const body = await readBody(req, res);
|
|
709
|
+
if (body === null) return;
|
|
710
|
+
try {
|
|
601
711
|
const msg = JSON.parse(body) as Record<string, unknown>;
|
|
602
712
|
|
|
603
713
|
// Feed into the hire listener's message handler
|
|
@@ -615,6 +725,7 @@ export async function runDaemon(foreground = false): Promise<void> {
|
|
|
615
725
|
// Dedup
|
|
616
726
|
const existing = db.getHireRequest(proposal.messageId);
|
|
617
727
|
if (existing) {
|
|
728
|
+
log({ event: "hire_duplicate", id: proposal.messageId, status: existing.status });
|
|
618
729
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
619
730
|
res.end(JSON.stringify({ status: existing.status, id: proposal.messageId }));
|
|
620
731
|
return;
|
|
@@ -669,16 +780,14 @@ export async function runDaemon(foreground = false): Promise<void> {
|
|
|
669
780
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
670
781
|
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
671
782
|
}
|
|
672
|
-
});
|
|
673
783
|
return;
|
|
674
784
|
}
|
|
675
785
|
|
|
676
786
|
// Handshake acknowledgment endpoint
|
|
677
787
|
if (pathname === "/handshake" && req.method === "POST") {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
try {
|
|
788
|
+
const body = await readBody(req, res);
|
|
789
|
+
if (body === null) return;
|
|
790
|
+
try {
|
|
682
791
|
const msg = JSON.parse(body);
|
|
683
792
|
log({ event: "handshake_received", from: msg.from, type: msg.type, note: msg.note });
|
|
684
793
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -687,171 +796,156 @@ export async function runDaemon(foreground = false): Promise<void> {
|
|
|
687
796
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
688
797
|
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
689
798
|
}
|
|
690
|
-
});
|
|
691
799
|
return;
|
|
692
800
|
}
|
|
693
801
|
|
|
694
802
|
// POST /hire/accepted — provider accepted, client notified
|
|
695
803
|
if (pathname === "/hire/accepted" && req.method === "POST") {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
await notifier.notifyHireAccepted(agreementId, agreementId);
|
|
706
|
-
}
|
|
707
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
708
|
-
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
709
|
-
} catch {
|
|
710
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
711
|
-
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
804
|
+
const body = await readBody(req, res);
|
|
805
|
+
if (body === null) return;
|
|
806
|
+
try {
|
|
807
|
+
const msg = JSON.parse(body) as Record<string, unknown>;
|
|
808
|
+
const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
|
|
809
|
+
const from = String(msg.from ?? "");
|
|
810
|
+
log({ event: "hire_accepted_inbound", agreementId, from });
|
|
811
|
+
if (config.notifications.notify_on_hire_accepted) {
|
|
812
|
+
await notifier.notifyHireAccepted(agreementId, agreementId);
|
|
712
813
|
}
|
|
713
|
-
|
|
814
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
815
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
816
|
+
} catch {
|
|
817
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
818
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
819
|
+
}
|
|
714
820
|
return;
|
|
715
821
|
}
|
|
716
822
|
|
|
717
823
|
// POST /message — off-chain negotiation message
|
|
718
824
|
if (pathname === "/message" && req.method === "POST") {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
});
|
|
825
|
+
const body = await readBody(req, res);
|
|
826
|
+
if (body === null) return;
|
|
827
|
+
try {
|
|
828
|
+
const msg = JSON.parse(body) as Record<string, unknown>;
|
|
829
|
+
const from = String(msg.from ?? "");
|
|
830
|
+
const to = String(msg.to ?? "");
|
|
831
|
+
const content = String(msg.content ?? "");
|
|
832
|
+
const timestamp = Number(msg.timestamp ?? Date.now());
|
|
833
|
+
log({ event: "message_received", from, to, timestamp, content_len: content.length });
|
|
834
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
835
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
836
|
+
} catch {
|
|
837
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
838
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
839
|
+
}
|
|
736
840
|
return;
|
|
737
841
|
}
|
|
738
842
|
|
|
739
843
|
// POST /delivery — provider committed a deliverable
|
|
740
844
|
if (pathname === "/delivery" && req.method === "POST") {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
await notifier.notifyDelivery(agreementId, deliverableHash, "");
|
|
756
|
-
}
|
|
757
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
758
|
-
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
759
|
-
} catch {
|
|
760
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
761
|
-
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
845
|
+
const body = await readBody(req, res);
|
|
846
|
+
if (body === null) return;
|
|
847
|
+
try {
|
|
848
|
+
const msg = JSON.parse(body) as Record<string, unknown>;
|
|
849
|
+
const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
|
|
850
|
+
const deliverableHash = String(msg.deliverableHash ?? msg.deliverable_hash ?? "");
|
|
851
|
+
const from = String(msg.from ?? "");
|
|
852
|
+
log({ event: "delivery_received", agreementId, deliverableHash, from });
|
|
853
|
+
// Update DB: mark delivered
|
|
854
|
+
const active = db.listActiveHireRequests();
|
|
855
|
+
const found = active.find(r => r.agreement_id === agreementId);
|
|
856
|
+
if (found) db.updateHireRequestStatus(found.id, "delivered");
|
|
857
|
+
if (config.notifications.notify_on_delivery) {
|
|
858
|
+
await notifier.notifyDelivery(agreementId, deliverableHash, "");
|
|
762
859
|
}
|
|
763
|
-
|
|
860
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
861
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
862
|
+
} catch {
|
|
863
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
864
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
865
|
+
}
|
|
764
866
|
return;
|
|
765
867
|
}
|
|
766
868
|
|
|
767
869
|
// POST /delivery/accepted — client accepted delivery, payment releasing
|
|
768
870
|
if (pathname === "/delivery/accepted" && req.method === "POST") {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
}
|
|
787
|
-
});
|
|
871
|
+
const body = await readBody(req, res);
|
|
872
|
+
if (body === null) return;
|
|
873
|
+
try {
|
|
874
|
+
const msg = JSON.parse(body) as Record<string, unknown>;
|
|
875
|
+
const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
|
|
876
|
+
const from = String(msg.from ?? "");
|
|
877
|
+
log({ event: "delivery_accepted_inbound", agreementId, from });
|
|
878
|
+
// Update DB: mark complete
|
|
879
|
+
const all = db.listActiveHireRequests();
|
|
880
|
+
const found = all.find(r => r.agreement_id === agreementId);
|
|
881
|
+
if (found) db.updateHireRequestStatus(found.id, "complete");
|
|
882
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
883
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
884
|
+
} catch {
|
|
885
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
886
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
887
|
+
}
|
|
788
888
|
return;
|
|
789
889
|
}
|
|
790
890
|
|
|
791
891
|
// POST /dispute — dispute raised against this agent
|
|
792
892
|
if (pathname === "/dispute" && req.method === "POST") {
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
await notifier.notifyDispute(agreementId, from);
|
|
804
|
-
}
|
|
805
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
806
|
-
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
807
|
-
} catch {
|
|
808
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
809
|
-
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
893
|
+
const body = await readBody(req, res);
|
|
894
|
+
if (body === null) return;
|
|
895
|
+
try {
|
|
896
|
+
const msg = JSON.parse(body) as Record<string, unknown>;
|
|
897
|
+
const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
|
|
898
|
+
const reason = String(msg.reason ?? "");
|
|
899
|
+
const from = String(msg.from ?? "");
|
|
900
|
+
log({ event: "dispute_received", agreementId, reason, from });
|
|
901
|
+
if (config.notifications.notify_on_dispute) {
|
|
902
|
+
await notifier.notifyDispute(agreementId, from);
|
|
810
903
|
}
|
|
811
|
-
|
|
904
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
905
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
906
|
+
} catch {
|
|
907
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
908
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
909
|
+
}
|
|
812
910
|
return;
|
|
813
911
|
}
|
|
814
912
|
|
|
815
913
|
// POST /dispute/resolved — dispute resolved by arbitrator
|
|
816
914
|
if (pathname === "/dispute/resolved" && req.method === "POST") {
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
}
|
|
832
|
-
});
|
|
915
|
+
const body = await readBody(req, res);
|
|
916
|
+
if (body === null) return;
|
|
917
|
+
try {
|
|
918
|
+
const msg = JSON.parse(body) as Record<string, unknown>;
|
|
919
|
+
const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
|
|
920
|
+
const outcome = String(msg.outcome ?? "");
|
|
921
|
+
const from = String(msg.from ?? "");
|
|
922
|
+
log({ event: "dispute_resolved_inbound", agreementId, outcome, from });
|
|
923
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
924
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
925
|
+
} catch {
|
|
926
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
927
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
928
|
+
}
|
|
833
929
|
return;
|
|
834
930
|
}
|
|
835
931
|
|
|
836
932
|
// POST /workroom/status — workroom lifecycle events
|
|
837
933
|
if (pathname === "/workroom/status" && req.method === "POST") {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
}
|
|
854
|
-
});
|
|
934
|
+
const body = await readBody(req, res);
|
|
935
|
+
if (body === null) return;
|
|
936
|
+
try {
|
|
937
|
+
const msg = JSON.parse(body) as Record<string, unknown>;
|
|
938
|
+
const event = String(msg.event ?? "");
|
|
939
|
+
const agentAddress = String(msg.agentAddress ?? config.wallet.contract_address);
|
|
940
|
+
const jobId = msg.jobId ? String(msg.jobId) : undefined;
|
|
941
|
+
const timestamp = Number(msg.timestamp ?? Date.now());
|
|
942
|
+
log({ event: "workroom_lifecycle", workroom_event: event, agentAddress, jobId, timestamp });
|
|
943
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
944
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
945
|
+
} catch {
|
|
946
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
947
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
948
|
+
}
|
|
855
949
|
return;
|
|
856
950
|
}
|
|
857
951
|
|
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,6 +91,7 @@ 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.
|
|
28
95
|
*/
|
|
29
96
|
export async function notifyAgent(
|
|
30
97
|
endpoint: string,
|
|
@@ -32,6 +99,12 @@ export async function notifyAgent(
|
|
|
32
99
|
payload: Record<string, unknown>
|
|
33
100
|
): Promise<boolean> {
|
|
34
101
|
if (!endpoint) return false;
|
|
102
|
+
try {
|
|
103
|
+
await validateEndpointUrl(endpoint);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.warn(`Warning: endpoint notify blocked (${endpoint}${path}): ${err instanceof Error ? err.message : String(err)}`);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
35
108
|
try {
|
|
36
109
|
const res = await fetch(`${endpoint}${path}`, {
|
|
37
110
|
method: "POST",
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
import { createProgram } from "./program";
|
|
3
3
|
import { startREPL } from "./repl";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const printMode = process.argv.includes("--print");
|
|
6
|
+
|
|
7
|
+
if (printMode) {
|
|
8
|
+
// --print mode: skip REPL entirely, suppress ANSI/spinners, run command, exit.
|
|
9
|
+
// Used by OpenClaw agents running arc402 commands via ACP.
|
|
10
|
+
process.argv = process.argv.filter((a) => a !== "--print");
|
|
11
|
+
process.env["NO_COLOR"] = "1";
|
|
12
|
+
process.env["FORCE_COLOR"] = "0";
|
|
13
|
+
process.env["ARC402_PRINT"] = "1";
|
|
14
|
+
const program = createProgram();
|
|
15
|
+
void program.parseAsync(process.argv).then(() => process.exit(0)).catch((e: unknown) => {
|
|
16
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
17
|
+
process.exit(1);
|
|
18
|
+
});
|
|
19
|
+
} else if (process.argv.length <= 2) {
|
|
6
20
|
// No subcommand — enter interactive REPL
|
|
7
21
|
void startREPL();
|
|
8
22
|
} else {
|
package/src/program.ts
CHANGED
|
@@ -27,6 +27,7 @@ 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";
|
|
@@ -72,6 +73,7 @@ export function createProgram(): Command {
|
|
|
72
73
|
registerContractInteractionCommands(program);
|
|
73
74
|
registerWatchtowerCommands(program);
|
|
74
75
|
registerColdStartCommands(program);
|
|
76
|
+
registerDoctorCommand(program);
|
|
75
77
|
registerMigrateCommands(program);
|
|
76
78
|
registerFeedCommand(program);
|
|
77
79
|
registerArenaCommands(program);
|