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.
Files changed (52) hide show
  1. package/dist/commands/config.d.ts.map +1 -1
  2. package/dist/commands/config.js +17 -1
  3. package/dist/commands/config.js.map +1 -1
  4. package/dist/commands/doctor.d.ts +3 -0
  5. package/dist/commands/doctor.d.ts.map +1 -0
  6. package/dist/commands/doctor.js +205 -0
  7. package/dist/commands/doctor.js.map +1 -0
  8. package/dist/commands/wallet.d.ts.map +1 -1
  9. package/dist/commands/wallet.js +467 -23
  10. package/dist/commands/wallet.js.map +1 -1
  11. package/dist/config.d.ts +1 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +11 -2
  14. package/dist/config.js.map +1 -1
  15. package/dist/daemon/index.d.ts.map +1 -1
  16. package/dist/daemon/index.js +294 -208
  17. package/dist/daemon/index.js.map +1 -1
  18. package/dist/endpoint-notify.d.ts +7 -0
  19. package/dist/endpoint-notify.d.ts.map +1 -1
  20. package/dist/endpoint-notify.js +104 -0
  21. package/dist/endpoint-notify.js.map +1 -1
  22. package/dist/index.js +15 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/program.d.ts.map +1 -1
  25. package/dist/program.js +2 -0
  26. package/dist/program.js.map +1 -1
  27. package/dist/repl.d.ts.map +1 -1
  28. package/dist/repl.js +565 -162
  29. package/dist/repl.js.map +1 -1
  30. package/dist/ui/banner.d.ts +2 -0
  31. package/dist/ui/banner.d.ts.map +1 -1
  32. package/dist/ui/banner.js +27 -18
  33. package/dist/ui/banner.js.map +1 -1
  34. package/dist/ui/format.d.ts.map +1 -1
  35. package/dist/ui/format.js +2 -0
  36. package/dist/ui/format.js.map +1 -1
  37. package/dist/ui/spinner.d.ts.map +1 -1
  38. package/dist/ui/spinner.js +11 -0
  39. package/dist/ui/spinner.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/commands/config.ts +18 -2
  42. package/src/commands/doctor.ts +172 -0
  43. package/src/commands/wallet.ts +512 -35
  44. package/src/config.ts +10 -1
  45. package/src/daemon/index.ts +234 -140
  46. package/src/endpoint-notify.ts +73 -0
  47. package/src/index.ts +15 -1
  48. package/src/program.ts +2 -0
  49. package/src/repl.ts +673 -197
  50. package/src/ui/banner.ts +26 -19
  51. package/src/ui/format.ts +1 -0
  52. package/src/ui/spinner.ts +10 -0
@@ -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>): net.Server {
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
- let body = "";
598
- req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
599
- req.on("end", async () => {
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
- let body = "";
679
- req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
680
- req.on("end", () => {
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
- let body = "";
697
- req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
698
- req.on("end", async () => {
699
- try {
700
- const msg = JSON.parse(body) as Record<string, unknown>;
701
- const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
702
- const from = String(msg.from ?? "");
703
- log({ event: "hire_accepted_inbound", agreementId, from });
704
- if (config.notifications.notify_on_hire_accepted) {
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
- let body = "";
720
- req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
721
- req.on("end", () => {
722
- try {
723
- const msg = JSON.parse(body) as Record<string, unknown>;
724
- const from = String(msg.from ?? "");
725
- const to = String(msg.to ?? "");
726
- const content = String(msg.content ?? "");
727
- const timestamp = Number(msg.timestamp ?? Date.now());
728
- log({ event: "message_received", from, to, timestamp, content_len: content.length });
729
- res.writeHead(200, { "Content-Type": "application/json" });
730
- res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
731
- } catch {
732
- res.writeHead(400, { "Content-Type": "application/json" });
733
- res.end(JSON.stringify({ error: "invalid_request" }));
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
- let body = "";
742
- req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
743
- req.on("end", async () => {
744
- try {
745
- const msg = JSON.parse(body) as Record<string, unknown>;
746
- const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
747
- const deliverableHash = String(msg.deliverableHash ?? msg.deliverable_hash ?? "");
748
- const from = String(msg.from ?? "");
749
- log({ event: "delivery_received", agreementId, deliverableHash, from });
750
- // Update DB: mark delivered
751
- const active = db.listActiveHireRequests();
752
- const found = active.find(r => r.agreement_id === agreementId);
753
- if (found) db.updateHireRequestStatus(found.id, "delivered");
754
- if (config.notifications.notify_on_delivery) {
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
- let body = "";
770
- req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
771
- req.on("end", () => {
772
- try {
773
- const msg = JSON.parse(body) as Record<string, unknown>;
774
- const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
775
- const from = String(msg.from ?? "");
776
- log({ event: "delivery_accepted_inbound", agreementId, from });
777
- // Update DB: mark complete
778
- const all = db.listActiveHireRequests();
779
- const found = all.find(r => r.agreement_id === agreementId);
780
- if (found) db.updateHireRequestStatus(found.id, "complete");
781
- res.writeHead(200, { "Content-Type": "application/json" });
782
- res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
783
- } catch {
784
- res.writeHead(400, { "Content-Type": "application/json" });
785
- res.end(JSON.stringify({ error: "invalid_request" }));
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
- let body = "";
794
- req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
795
- req.on("end", async () => {
796
- try {
797
- const msg = JSON.parse(body) as Record<string, unknown>;
798
- const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
799
- const reason = String(msg.reason ?? "");
800
- const from = String(msg.from ?? "");
801
- log({ event: "dispute_received", agreementId, reason, from });
802
- if (config.notifications.notify_on_dispute) {
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
- let body = "";
818
- req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
819
- req.on("end", () => {
820
- try {
821
- const msg = JSON.parse(body) as Record<string, unknown>;
822
- const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
823
- const outcome = String(msg.outcome ?? "");
824
- const from = String(msg.from ?? "");
825
- log({ event: "dispute_resolved_inbound", agreementId, outcome, from });
826
- res.writeHead(200, { "Content-Type": "application/json" });
827
- res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
828
- } catch {
829
- res.writeHead(400, { "Content-Type": "application/json" });
830
- res.end(JSON.stringify({ error: "invalid_request" }));
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
- let body = "";
839
- req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
840
- req.on("end", () => {
841
- try {
842
- const msg = JSON.parse(body) as Record<string, unknown>;
843
- const event = String(msg.event ?? "");
844
- const agentAddress = String(msg.agentAddress ?? config.wallet.contract_address);
845
- const jobId = msg.jobId ? String(msg.jobId) : undefined;
846
- const timestamp = Number(msg.timestamp ?? Date.now());
847
- log({ event: "workroom_lifecycle", workroom_event: event, agentAddress, jobId, timestamp });
848
- res.writeHead(200, { "Content-Type": "application/json" });
849
- res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
850
- } catch {
851
- res.writeHead(400, { "Content-Type": "application/json" });
852
- res.end(JSON.stringify({ error: "invalid_request" }));
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
 
@@ -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
- if (process.argv.length <= 2) {
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);