@decentnetwork/lan 0.1.0 → 0.1.3

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.
Binary file
Binary file
Binary file
Binary file
@@ -104,6 +104,31 @@ export declare function cmdFriendAccept(args: {
104
104
  export declare function cmdFriendsList(args: {
105
105
  configDir?: string;
106
106
  }): Promise<void>;
107
+ /**
108
+ * List queued friend-requests held by the running daemon (the ones
109
+ * received while `friends.autoAccept` was false). Goes over IPC —
110
+ * the daemon must be up.
111
+ */
112
+ export declare function cmdFriendsPending(args: {
113
+ configDir?: string;
114
+ }): Promise<void>;
115
+ /**
116
+ * Accept a queued friend-request by userid. Routes through IPC;
117
+ * the daemon's own Carrier peer does the actual acceptFriendRequest
118
+ * call and drops the entry from disk. No daemon shutdown needed.
119
+ */
120
+ export declare function cmdFriendsAccept(args: {
121
+ userid: string;
122
+ configDir?: string;
123
+ }): Promise<void>;
124
+ /**
125
+ * Drop a queued friend-request without accepting. Sender doesn't
126
+ * get a notification — they just see no acceptance.
127
+ */
128
+ export declare function cmdFriendsReject(args: {
129
+ userid: string;
130
+ configDir?: string;
131
+ }): Promise<void>;
107
132
  /**
108
133
  * Show audit log
109
134
  */
@@ -198,12 +223,20 @@ export declare function cmdDiag(args: {
198
223
  configDir?: string;
199
224
  }): Promise<void>;
200
225
  /**
201
- * Enable dora integration and add a dora server's userid to the
202
- * registry list. Subsequent `agentnet up` runs will register with
203
- * dora to get a virtual IP and fetch the peer roster automatically.
226
+ * Enable dora integration. Accepts either `--address` (preferred the
227
+ * full Carrier address of the dora server; we derive its userid and
228
+ * also send the one-time friend-request automatically) or `--userid`
229
+ * (legacy — expects the operator to have already friended the server).
230
+ *
231
+ * The one-shot `--address` path is the right UX: an operator setting
232
+ * up a fresh node only knows "use this dora server, here's its
233
+ * address" — they shouldn't have to know that dora is also a Carrier
234
+ * peer, that they need to friend it first, or what the difference
235
+ * between userid and address is.
204
236
  */
205
237
  export declare function cmdDoraEnable(args: {
206
- userid: string;
238
+ address?: string;
239
+ userid?: string;
207
240
  configDir?: string;
208
241
  }): Promise<void>;
209
242
  /**
@@ -405,8 +405,15 @@ export async function cmdFriendRequest(args) {
405
405
  const waitMs = args.waitMs ?? 30000;
406
406
  console.log(`Waiting ${waitMs}ms for relay delivery...`);
407
407
  await new Promise((r) => setTimeout(r, waitMs));
408
+ const myPubkey = peer.pubkey();
409
+ const myUserid = peer.userid();
408
410
  await peer.stop();
409
- console.log(`Friend request sent. The recipient must run 'agentnet friend-accept --pubkey ${peer.pubkey()}'.`);
411
+ console.log(`Friend request sent.`);
412
+ console.log(`If the recipient's daemon is running with friends.autoAccept (the default), it has already accepted —`);
413
+ console.log(`no action needed on their side. They can confirm with 'agentnet diag' and look for userid ${myUserid}.`);
414
+ console.log(`If their daemon was offline, the request is queued via the express relay; their daemon will`);
415
+ console.log(`pick it up on next start (and still auto-accept). Only run 'agentnet friend-accept --pubkey ${myPubkey}'`);
416
+ console.log(`manually if their daemon is down AND they've disabled autoAccept.`);
410
417
  }
411
418
  /**
412
419
  * Accept a pending friend request.
@@ -500,10 +507,10 @@ export async function cmdFriendsList(args) {
500
507
  for (const friend of friends) {
501
508
  console.log(``);
502
509
  console.log(` ${friend.name || "(unnamed)"} status=${friend.status}`);
503
- console.log(` pubkey: ${friend.pubkey}`);
504
- if (friend.userid && friend.userid !== friend.pubkey) {
505
- console.log(` userid: ${friend.userid}`);
506
- }
510
+ // userid is Carrier's first-class identifier; show that, hide
511
+ // the raw hex pubkey unless someone explicitly needs it.
512
+ const userid = friend.userid && friend.userid !== friend.pubkey ? friend.userid : friend.pubkey;
513
+ console.log(` userid: ${userid}`);
507
514
  if (friend.address)
508
515
  console.log(` address: ${friend.address}`);
509
516
  if (friend.acceptedAt)
@@ -511,6 +518,69 @@ export async function cmdFriendsList(args) {
511
518
  }
512
519
  await peer.stop();
513
520
  }
521
+ /**
522
+ * List queued friend-requests held by the running daemon (the ones
523
+ * received while `friends.autoAccept` was false). Goes over IPC —
524
+ * the daemon must be up.
525
+ */
526
+ export async function cmdFriendsPending(args) {
527
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
528
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
529
+ if (daemonPid(config) === null) {
530
+ throw new Error("Daemon not running — pending friend-requests are held by the daemon. Start it with 'agentnet up' first.");
531
+ }
532
+ const res = await ipcCall(config, { op: "friends-pending" });
533
+ if (!res.ok)
534
+ throw new Error(`Daemon refused: ${res.error}`);
535
+ const list = res.data?.pending ?? [];
536
+ if (list.length === 0) {
537
+ console.log("No pending friend-requests.");
538
+ return;
539
+ }
540
+ console.log(`Pending friend-requests (${list.length}):`);
541
+ for (const e of list) {
542
+ console.log("");
543
+ console.log(` ${e.name || "(unnamed)"}`);
544
+ console.log(` userid: ${e.userid}`);
545
+ if (e.hello)
546
+ console.log(` hello: ${e.hello}`);
547
+ console.log(` arrived: ${e.arrivedAt}`);
548
+ }
549
+ console.log("");
550
+ console.log("Accept with: agentnet friends accept --userid <USERID>");
551
+ console.log("Reject with: agentnet friends reject --userid <USERID>");
552
+ }
553
+ /**
554
+ * Accept a queued friend-request by userid. Routes through IPC;
555
+ * the daemon's own Carrier peer does the actual acceptFriendRequest
556
+ * call and drops the entry from disk. No daemon shutdown needed.
557
+ */
558
+ export async function cmdFriendsAccept(args) {
559
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
560
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
561
+ if (daemonPid(config) === null) {
562
+ throw new Error("Daemon not running. Start it with 'agentnet up' first — pending friend-requests are managed by the live daemon.");
563
+ }
564
+ const res = await ipcCall(config, { op: "friends-accept", userid: args.userid });
565
+ if (!res.ok)
566
+ throw new Error(`Daemon refused: ${res.error}`);
567
+ console.log(`Accepted ${args.userid}.`);
568
+ }
569
+ /**
570
+ * Drop a queued friend-request without accepting. Sender doesn't
571
+ * get a notification — they just see no acceptance.
572
+ */
573
+ export async function cmdFriendsReject(args) {
574
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
575
+ const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
576
+ if (daemonPid(config) === null) {
577
+ throw new Error("Daemon not running. Start it with 'agentnet up' first.");
578
+ }
579
+ const res = await ipcCall(config, { op: "friends-reject", userid: args.userid });
580
+ if (!res.ok)
581
+ throw new Error(`Daemon refused: ${res.error}`);
582
+ console.log(`Rejected ${args.userid}.`);
583
+ }
514
584
  /**
515
585
  * Show audit log
516
586
  */
@@ -861,27 +931,75 @@ export async function cmdDiag(args) {
861
931
  console.log(JSON.stringify(res.data, null, 2));
862
932
  }
863
933
  /**
864
- * Enable dora integration and add a dora server's userid to the
865
- * registry list. Subsequent `agentnet up` runs will register with
866
- * dora to get a virtual IP and fetch the peer roster automatically.
934
+ * Enable dora integration. Accepts either `--address` (preferred the
935
+ * full Carrier address of the dora server; we derive its userid and
936
+ * also send the one-time friend-request automatically) or `--userid`
937
+ * (legacy — expects the operator to have already friended the server).
938
+ *
939
+ * The one-shot `--address` path is the right UX: an operator setting
940
+ * up a fresh node only knows "use this dora server, here's its
941
+ * address" — they shouldn't have to know that dora is also a Carrier
942
+ * peer, that they need to friend it first, or what the difference
943
+ * between userid and address is.
867
944
  */
868
945
  export async function cmdDoraEnable(args) {
946
+ if (!args.address && !args.userid) {
947
+ throw new Error("Need one of --address (preferred) or --userid");
948
+ }
949
+ // Derive userid from address when given an address. Address format:
950
+ // base58(pubkey || nospam || checksum); userid: base58(pubkey).
951
+ let userid = args.userid;
952
+ if (args.address) {
953
+ const { carrierIdFromAddress } = await import("@decentnetwork/peer");
954
+ try {
955
+ userid = carrierIdFromAddress(args.address);
956
+ }
957
+ catch (err) {
958
+ throw new Error(`--address didn't parse as a Carrier address: ${err instanceof Error ? err.message : err}`);
959
+ }
960
+ }
869
961
  const dir = args.configDir || ConfigLoader.defaultConfigDir();
870
962
  const configPath = resolve(dir, "config.yaml");
871
963
  const config = await ConfigLoader.load(configPath);
964
+ // Send the friend-request first when we have an address. We route
965
+ // it via the daemon's IPC if it's running, or fall back to a
966
+ // standalone short-lived Peer when it isn't (same logic as
967
+ // cmdFriendRequest).
968
+ if (args.address) {
969
+ console.log(`Friending dora server at ${args.address.slice(0, 24)}...`);
970
+ try {
971
+ await cmdFriendRequest({
972
+ address: args.address,
973
+ hello: "decentlan: enable dora",
974
+ waitMs: 8000,
975
+ configDir: args.configDir,
976
+ });
977
+ }
978
+ catch (err) {
979
+ // If the SDK already knows this address as an accepted friend
980
+ // it short-circuits — treat that as success, not failure.
981
+ const msg = err instanceof Error ? err.message : String(err);
982
+ if (!/already (accepted|a friend|friend)/i.test(msg)) {
983
+ console.warn(`(friend-request had a warning: ${msg})`);
984
+ console.warn(`Proceeding with config update anyway — re-run 'agentnet friend-request' later if you see "friend offline" in the daemon log.`);
985
+ }
986
+ }
987
+ }
872
988
  const existing = config.dora ?? { enabled: false, userids: [], refreshIntervalMs: 60_000 };
873
989
  const userids = new Set(existing.userids ?? []);
874
- userids.add(args.userid);
990
+ userids.add(userid);
875
991
  config.dora = {
876
992
  enabled: true,
877
993
  userids: [...userids],
878
994
  refreshIntervalMs: existing.refreshIntervalMs ?? 60_000,
879
995
  };
880
996
  await ConfigLoader.save(config, configPath);
881
- console.log(`Dora enabled. Server userid added: ${args.userid}`);
997
+ console.log(`Dora enabled. Server userid added: ${userid}`);
882
998
  console.log(`Total dora servers configured: ${config.dora.userids?.length}`);
883
- console.log(`The dora server must be a Carrier friend — add it with 'agentnet friend-request' if you haven't.`);
884
- console.log(`Restart the daemon to take effect.`);
999
+ if (!args.address) {
1000
+ console.log(`Heads-up: passed --userid only; if you haven't friended the dora server, run 'agentnet friend-request --address <ADDRESS>' before starting the daemon.`);
1001
+ }
1002
+ console.log(`Restart the daemon (or just 'agentnet up') to take effect.`);
885
1003
  }
886
1004
  /**
887
1005
  * Disable dora integration without losing the configured server list.
package/dist/cli/index.js CHANGED
@@ -10,7 +10,7 @@ import { hideBin } from "yargs/helpers";
10
10
  // Belt-and-braces — also raise it here in case the CLI is run directly
11
11
  // (e.g. `node dist/cli/index.js` rather than via dist/index.js).
12
12
  EventEmitter.defaultMaxListeners = 100;
13
- import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDiag, cmdDnsInstall, cmdDnsHosts, } from "./commands.js";
13
+ import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyUse, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDiag, cmdDnsInstall, cmdDnsHosts, } from "./commands.js";
14
14
  async function main() {
15
15
  await yargs(hideBin(process.argv))
16
16
  .scriptName("agentnet")
@@ -126,8 +126,21 @@ async function main() {
126
126
  configDir: argv["config-dir"],
127
127
  });
128
128
  })
129
- .command("friends list", "List Carrier friends", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
129
+ .command("friends list", "List Carrier friends (daemon must be down)", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
130
130
  await cmdFriendsList({ configDir: argv["config-dir"] });
131
+ })
132
+ .command("friends pending", "List queued friend-requests (over IPC; daemon must be up)", (y) => y.option("config-dir", { type: "string" }), async (argv) => {
133
+ await cmdFriendsPending({ configDir: argv["config-dir"] });
134
+ })
135
+ .command("friends accept", "Accept a queued friend-request by userid (over IPC; daemon stays up)", (y) => y
136
+ .option("userid", { type: "string", demandOption: true, describe: "Sender's Carrier userid (base58)" })
137
+ .option("config-dir", { type: "string" }), async (argv) => {
138
+ await cmdFriendsAccept({ userid: argv.userid, configDir: argv["config-dir"] });
139
+ })
140
+ .command("friends reject", "Drop a queued friend-request by userid (over IPC; daemon stays up)", (y) => y
141
+ .option("userid", { type: "string", demandOption: true })
142
+ .option("config-dir", { type: "string" }), async (argv) => {
143
+ await cmdFriendsReject({ userid: argv.userid, configDir: argv["config-dir"] });
131
144
  })
132
145
  .command("audit log", "View audit log", (y) => y.option("tail", { type: "number", default: 50 }).option("config-dir", { type: "string" }), async (argv) => {
133
146
  await cmdAuditLog({ tail: argv.tail, configDir: argv["config-dir"] });
@@ -170,10 +183,21 @@ async function main() {
170
183
  // parent handler — never invoked because demandCommand above
171
184
  })
172
185
  .command("dora", "Manage the dora (DHCP-style) registry integration (run 'agentnet dora --help')", (y) => y
173
- .command("enable", "Enable dora and register a server's Carrier userid", (yy) => yy
174
- .option("userid", { type: "string", demandOption: true, describe: "Dora server's Carrier userid" })
175
- .option("config-dir", { type: "string" }), async (argv) => {
176
- await cmdDoraEnable({ userid: argv.userid, configDir: argv["config-dir"] });
186
+ .command("enable", "Enable dora preferred: --address <addr> (we friend + derive userid); legacy: --userid <id>", (yy) => yy
187
+ .option("address", { type: "string", describe: "Dora server's full Carrier address — friends it and derives the userid for you" })
188
+ .option("userid", { type: "string", describe: "Legacy: server's userid; only use if you've already friended it separately" })
189
+ .option("config-dir", { type: "string" })
190
+ .check((argv) => {
191
+ if (!argv.address && !argv.userid) {
192
+ throw new Error("Need one of --address (preferred) or --userid");
193
+ }
194
+ return true;
195
+ }), async (argv) => {
196
+ await cmdDoraEnable({
197
+ address: argv.address,
198
+ userid: argv.userid,
199
+ configDir: argv["config-dir"],
200
+ });
177
201
  })
178
202
  .command("disable", "Disable dora integration (keep configured server list)", (yy) => yy.option("config-dir", { type: "string" }), async (argv) => {
179
203
  await cmdDoraDisable({ configDir: argv["config-dir"] });
@@ -29,11 +29,22 @@ export interface IpcHandlers {
29
29
  * Used by `agentnet diag` to inspect a live daemon without
30
30
  * restarting it with AGENTNET_LOG_LEVEL=debug. */
31
31
  diag: () => Promise<Record<string, unknown>>;
32
+ /** List queued friend-requests that arrived while autoAccept was
33
+ * off. Returns an array of {userid, name, hello, arrivedAt}. */
34
+ friendsPending: () => Promise<Record<string, unknown>>;
35
+ /** Accept a queued friend-request by userid. Drops it from the
36
+ * queue and calls peer.acceptFriendRequest. Idempotent — no-op
37
+ * if userid isn't in the queue. */
38
+ friendsAccept: (userid: string) => Promise<void>;
39
+ /** Reject (drop) a queued friend-request by userid. Doesn't
40
+ * notify the sender — they'll just see no acceptance. */
41
+ friendsReject: (userid: string) => Promise<void>;
32
42
  }
33
43
  export interface IpcRequest {
34
- op: "friend-request" | "ping" | "diag";
44
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject";
35
45
  address?: string;
36
46
  hello?: string;
47
+ userid?: string;
37
48
  }
38
49
  export interface IpcResponseOk {
39
50
  ok: true;
@@ -130,6 +130,20 @@ export class IpcServer {
130
130
  }
131
131
  case "diag":
132
132
  return await this.handlers.diag();
133
+ case "friends-pending":
134
+ return await this.handlers.friendsPending();
135
+ case "friends-accept": {
136
+ if (!req.userid)
137
+ throw new Error("userid is required");
138
+ await this.handlers.friendsAccept(req.userid);
139
+ return;
140
+ }
141
+ case "friends-reject": {
142
+ if (!req.userid)
143
+ throw new Error("userid is required");
144
+ await this.handlers.friendsReject(req.userid);
145
+ return;
146
+ }
133
147
  default:
134
148
  throw new Error(`unknown op: ${req.op}`);
135
149
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pending-friend-request store.
3
+ *
4
+ * When `friends.autoAccept` is false (or for any other reason the
5
+ * operator wants to gate inbound friend requests), the daemon must
6
+ * NOT block the running session waiting for a CLI dance. It buffers
7
+ * each incoming request to disk and exposes IPC ops so an operator
8
+ * can list / accept / reject them on their own time, without
9
+ * stopping the daemon. Lost-on-restart pending requests would be
10
+ * frustrating, so the queue is persisted.
11
+ *
12
+ * File format: a single JSON array under
13
+ * `<configDir>/pending-friends.json`. Each entry is the same shape
14
+ * the SDK emits in the friend-request event, plus an arrivedAt
15
+ * timestamp.
16
+ */
17
+ export interface PendingFriendEntry {
18
+ /** Carrier userid (base58, same 32 pubkey bytes as the hex form
19
+ * the SDK emits — we store the userid form because that's what
20
+ * every user-facing surface uses). */
21
+ userid: string;
22
+ /** Hex pubkey, kept for compatibility with SDK calls that expect
23
+ * that form (acceptFriendRequest takes the pubkey/userid string
24
+ * the original event carried). */
25
+ pubkey: string;
26
+ name?: string;
27
+ hello?: string;
28
+ /** ISO 8601 timestamp; helps operators triage queues of pending
29
+ * requests. */
30
+ arrivedAt: string;
31
+ }
32
+ export declare class PendingFriendsStore {
33
+ private filePath;
34
+ private logger;
35
+ private entries;
36
+ constructor(filePath: string);
37
+ private load;
38
+ private save;
39
+ list(): PendingFriendEntry[];
40
+ /**
41
+ * Add (or replace) a pending request. The same userid arriving
42
+ * twice replaces the older entry — operators see the latest
43
+ * `hello` / `name` / `arrivedAt`, never a stale duplicate.
44
+ */
45
+ add(entry: PendingFriendEntry): void;
46
+ /**
47
+ * Drop an entry by userid. Returns the removed entry (so the
48
+ * caller has the original pubkey to pass to the SDK's
49
+ * acceptFriendRequest), or null if not present.
50
+ */
51
+ remove(userid: string): PendingFriendEntry | null;
52
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Pending-friend-request store.
3
+ *
4
+ * When `friends.autoAccept` is false (or for any other reason the
5
+ * operator wants to gate inbound friend requests), the daemon must
6
+ * NOT block the running session waiting for a CLI dance. It buffers
7
+ * each incoming request to disk and exposes IPC ops so an operator
8
+ * can list / accept / reject them on their own time, without
9
+ * stopping the daemon. Lost-on-restart pending requests would be
10
+ * frustrating, so the queue is persisted.
11
+ *
12
+ * File format: a single JSON array under
13
+ * `<configDir>/pending-friends.json`. Each entry is the same shape
14
+ * the SDK emits in the friend-request event, plus an arrivedAt
15
+ * timestamp.
16
+ */
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
18
+ import { dirname } from "path";
19
+ import { Logger } from "../utils/logger.js";
20
+ export class PendingFriendsStore {
21
+ filePath;
22
+ logger;
23
+ entries = [];
24
+ constructor(filePath) {
25
+ this.filePath = filePath;
26
+ this.logger = new Logger({ prefix: "PendingFriends" });
27
+ this.load();
28
+ }
29
+ load() {
30
+ if (!existsSync(this.filePath))
31
+ return;
32
+ try {
33
+ const raw = readFileSync(this.filePath, "utf-8");
34
+ const parsed = JSON.parse(raw);
35
+ if (Array.isArray(parsed)) {
36
+ this.entries = parsed.filter((e) => typeof e === "object" &&
37
+ e !== null &&
38
+ typeof e.userid === "string");
39
+ }
40
+ }
41
+ catch (err) {
42
+ this.logger.warn(`Could not parse ${this.filePath}: ${err}`);
43
+ }
44
+ }
45
+ save() {
46
+ try {
47
+ mkdirSync(dirname(this.filePath), { recursive: true });
48
+ writeFileSync(this.filePath, JSON.stringify(this.entries, null, 2), "utf-8");
49
+ }
50
+ catch (err) {
51
+ this.logger.warn(`Could not write ${this.filePath}: ${err}`);
52
+ }
53
+ }
54
+ list() {
55
+ return [...this.entries];
56
+ }
57
+ /**
58
+ * Add (or replace) a pending request. The same userid arriving
59
+ * twice replaces the older entry — operators see the latest
60
+ * `hello` / `name` / `arrivedAt`, never a stale duplicate.
61
+ */
62
+ add(entry) {
63
+ this.entries = this.entries.filter((e) => e.userid !== entry.userid);
64
+ this.entries.push(entry);
65
+ this.save();
66
+ }
67
+ /**
68
+ * Drop an entry by userid. Returns the removed entry (so the
69
+ * caller has the original pubkey to pass to the SDK's
70
+ * acceptFriendRequest), or null if not present.
71
+ */
72
+ remove(userid) {
73
+ const idx = this.entries.findIndex((e) => e.userid === userid);
74
+ if (idx < 0)
75
+ return null;
76
+ const [removed] = this.entries.splice(idx, 1);
77
+ this.save();
78
+ return removed;
79
+ }
80
+ }
@@ -32,6 +32,7 @@ export declare class DaemonServer {
32
32
  private doraIntegration?;
33
33
  private ipcServer?;
34
34
  private dnsServer?;
35
+ private pendingFriends?;
35
36
  private startedAt;
36
37
  private isRunning;
37
38
  private pidFile?;
@@ -16,6 +16,7 @@ import { ConnectProxy } from "../proxy/connect-proxy.js";
16
16
  import { DoraIntegration } from "../dora/dora-integration.js";
17
17
  import { DnsServer } from "../dns/server.js";
18
18
  import { IpcServer, ipcSocketPath } from "./ipc.js";
19
+ import { PendingFriendsStore } from "./pending-friends.js";
19
20
  import { Logger } from "../utils/logger.js";
20
21
  export class DaemonServer {
21
22
  config;
@@ -35,6 +36,7 @@ export class DaemonServer {
35
36
  doraIntegration;
36
37
  ipcServer;
37
38
  dnsServer;
39
+ pendingFriends;
38
40
  startedAt = 0;
39
41
  isRunning = false;
40
42
  pidFile;
@@ -107,6 +109,31 @@ export class DaemonServer {
107
109
  friendRequest: async (address, hello) => {
108
110
  await this.peerManager.sendFriendRequest(address, hello);
109
111
  },
112
+ friendsPending: async () => {
113
+ const entries = this.pendingFriends?.list() ?? [];
114
+ // Hide the legacy hex `pubkey` field from the wire — the
115
+ // CLI side only needs userid + metadata to display + accept.
116
+ return {
117
+ pending: entries.map((e) => ({
118
+ userid: e.userid,
119
+ name: e.name,
120
+ hello: e.hello,
121
+ arrivedAt: e.arrivedAt,
122
+ })),
123
+ };
124
+ },
125
+ friendsAccept: async (userid) => {
126
+ const entry = this.pendingFriends?.remove(userid);
127
+ if (!entry)
128
+ throw new Error(`No pending friend-request for userid ${userid}`);
129
+ await this.peerManager.acceptFriendRequest(entry.pubkey);
130
+ },
131
+ friendsReject: async (userid) => {
132
+ const entry = this.pendingFriends?.remove(userid);
133
+ if (!entry)
134
+ throw new Error(`No pending friend-request for userid ${userid}`);
135
+ // No-op at the Carrier level — sender just sees no acceptance.
136
+ },
110
137
  diag: async () => {
111
138
  // Snapshot of everything an operator needs to debug why
112
139
  // packets aren't moving: forwarding counters, friend
@@ -188,24 +215,49 @@ export class DaemonServer {
188
215
  });
189
216
  }
190
217
  // Live friend-request handling. The daemon stays up and accepts
191
- // (or just logs) incoming requests — no more `friend-accept --wait`
192
- // ceremony where the operator has to stop the daemon. Default is
193
- // auto-accept; the Carrier friend store IS the trust boundary, and
194
- // requests only reach a peer whose address was deliberately shared.
218
+ // (or queues) every incoming request — no `friend-accept --wait`
219
+ // ceremony where the operator has to stop the daemon. Default
220
+ // is auto-accept; the Carrier friend store IS the trust
221
+ // boundary, and requests only reach a peer whose address was
222
+ // deliberately shared.
223
+ //
224
+ // When autoAccept is off, the request is persisted to
225
+ // pending-friends.json on disk. The operator inspects/handles
226
+ // it later via the IPC-routed CLI:
227
+ // agentnet friends pending
228
+ // agentnet friends accept --userid <userid>
229
+ // agentnet friends reject --userid <userid>
230
+ // Pending requests survive daemon restarts.
195
231
  const autoAcceptFriends = this.config.friends?.autoAccept ?? true;
232
+ const pendingPath = resolve(this.config.carrier.dataDir, "..", "pending-friends.json");
233
+ this.pendingFriends = new PendingFriendsStore(pendingPath);
234
+ const pubkeyHexToUserid = async (pubkeyHex) => {
235
+ const { carrierIdFromPublicKey } = await import("@decentnetwork/peer");
236
+ const bytes = Buffer.from(pubkeyHex, "hex");
237
+ return carrierIdFromPublicKey(new Uint8Array(bytes));
238
+ };
196
239
  this.peerManager.on("friend-request", (req) => {
197
- const who = `${req.name || "(unnamed)"} ${req.pubkey.slice(0, 16)}...`;
198
- const hello = req.hello ? ` hello="${req.hello.slice(0, 60)}"` : "";
199
- if (autoAcceptFriends) {
200
- this.logger.info(`Friend request from ${who}${hello} — auto-accepting`);
201
- this.peerManager?.acceptFriendRequest(req.pubkey).catch((err) => {
202
- this.logger.warn(`Auto-accept failed for ${who}: ${err}`);
203
- });
204
- }
205
- else {
206
- this.logger.info(`Friend request from ${who}${hello} — NOT auto-accepting ` +
207
- `(friends.autoAccept=false; accept manually later)`);
208
- }
240
+ void pubkeyHexToUserid(req.pubkey).then((userid) => {
241
+ const who = `${req.name || "(unnamed)"} ${userid}`;
242
+ const hello = req.hello ? ` hello="${req.hello.slice(0, 60)}"` : "";
243
+ if (autoAcceptFriends) {
244
+ this.logger.info(`Friend request from ${who}${hello} — auto-accepting`);
245
+ this.peerManager?.acceptFriendRequest(req.pubkey).catch((err) => {
246
+ this.logger.warn(`Auto-accept failed for ${who}: ${err}`);
247
+ });
248
+ }
249
+ else {
250
+ this.logger.info(`Friend request from ${who}${hello} — queued (friends.autoAccept=false). ` +
251
+ `Run 'agentnet friends pending' to triage.`);
252
+ this.pendingFriends?.add({
253
+ userid,
254
+ pubkey: req.pubkey,
255
+ name: req.name,
256
+ hello: req.hello,
257
+ arrivedAt: new Date().toISOString(),
258
+ });
259
+ }
260
+ });
209
261
  });
210
262
  // NOTE: auto-IPAM (hash-derived virtual IP per userid) was tried and
211
263
  // reverted — it produced IPs like 10.86.175.35 that conflicted with
package/docs/INSTALL.md CHANGED
@@ -41,17 +41,20 @@ agentnet --help
41
41
  # 1. Generate this machine's Carrier identity + default config
42
42
  agentnet init --name my-laptop
43
43
 
44
- # 2. (Once per network) friend the dora name server.
45
- # Get the address from whoever runs dora.
46
- agentnet friend-request --address Jt7w1pKkyLT5GVue9h6ZPkjg1EeuuTbD6JVSLycXLsdm6nvBGSUd
44
+ # 2. Point at the dora name server. Pass the FULL ADDRESS (whoever
45
+ # runs dora publishes it). This one command friends dora,
46
+ # derives its userid, and writes the config — no separate
47
+ # friend-request step needed.
48
+ agentnet dora enable --address <dora's Carrier address>
47
49
 
48
- # 3. Tell decentlan to use that dora server
49
- agentnet dora enable --userid 98rsHv17h8G6AP9RagyrBiT1kmw4cn8MFPEembS6ZVjv
50
-
51
- # 4. Start the daemon (needs sudo for the TUN device)
50
+ # 3. Start the daemon (needs sudo for the TUN device)
52
51
  sudo $(which agentnet) up --real-tun
53
52
  ```
54
53
 
54
+ Three commands, no "userid vs address" gymnastics. The daemon will
55
+ register with dora, pull the roster, and auto-friend every other
56
+ peer in the network.
57
+
55
58
  The daemon will:
56
59
 
57
60
  1. Open a Carrier connection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",