@decentnetwork/lan 0.1.277 → 0.1.279

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.
@@ -95,8 +95,11 @@ export declare class PeerManager extends EventEmitter {
95
95
  sendAppPacket(userid: string, id: number, data: Uint8Array): Promise<void>;
96
96
  /** Send a file INLINE over the message channel (FileModel JSON envelope,
97
97
  * bulkmsg-split) — the only file path native iOS/C Carrier clients can
98
- * receive online. Use for native friends; JS friends keep sendFile(). */
99
- sendInlineFile(userid: string, data: Uint8Array, name: string): Promise<void>;
98
+ * receive online. Use for native friends; JS friends keep sendFile().
99
+ * Returns the SDK's delivery verdict: "acked" is the only outcome that
100
+ * justifies a "sent" checkmark. Older SDKs (< 0.1.140) resolve void —
101
+ * treat that as "accepted" (bytes out, delivery unproven). */
102
+ sendInlineFile(userid: string, data: Uint8Array, name: string): Promise<"acked" | "accepted" | "offline">;
100
103
  /**
101
104
  * Send a WebRTC call-signaling payload to a friend over the Carrier
102
105
  * friend-invite channel (the "carrier" extension), the exact transport the
@@ -186,11 +186,15 @@ export class PeerManager extends EventEmitter {
186
186
  }
187
187
  /** Send a file INLINE over the message channel (FileModel JSON envelope,
188
188
  * bulkmsg-split) — the only file path native iOS/C Carrier clients can
189
- * receive online. Use for native friends; JS friends keep sendFile(). */
189
+ * receive online. Use for native friends; JS friends keep sendFile().
190
+ * Returns the SDK's delivery verdict: "acked" is the only outcome that
191
+ * justifies a "sent" checkmark. Older SDKs (< 0.1.140) resolve void —
192
+ * treat that as "accepted" (bytes out, delivery unproven). */
190
193
  async sendInlineFile(userid, data, name) {
191
194
  if (!this.peer)
192
195
  throw new Error("Peer not created. Call create() first.");
193
- await this.peer.sendInlineFile(userid, { name, data });
196
+ const r = (await this.peer.sendInlineFile(userid, { name, data }));
197
+ return r?.delivery ?? "accepted";
194
198
  }
195
199
  /**
196
200
  * Send a WebRTC call-signaling payload to a friend over the Carrier
@@ -533,6 +533,20 @@ export declare function cmdServiceRestart(args: {
533
533
  * the new code without a sudo prompt — the daemon already has the
534
534
  * privileges it needs to rebind the TUN.
535
535
  */
536
+ /**
537
+ * One-shot repair for the classic root-owned-config-dir mess.
538
+ *
539
+ * The daemon runs as root for the TUN device; the Beagle chat UI runs as you.
540
+ * Every file the daemon created under ~/.agentnet before this was fixed came
541
+ * out root-owned, so the UI's writes into downloads/ and outbox/ fail with
542
+ * EACCES and file sends die. New daemons repair this themselves at startup —
543
+ * this is the command for an install that predates that, or one whose daemon
544
+ * cannot be restarted right now.
545
+ */
546
+ export declare function cmdFixPerms(args: {
547
+ configDir?: string;
548
+ user?: string;
549
+ }): Promise<void>;
536
550
  export declare function cmdRestart(args: {
537
551
  configDir?: string;
538
552
  }): Promise<void>;
@@ -2665,6 +2665,54 @@ export async function cmdServiceRestart(args) {
2665
2665
  * the new code without a sudo prompt — the daemon already has the
2666
2666
  * privileges it needs to rebind the TUN.
2667
2667
  */
2668
+ /**
2669
+ * One-shot repair for the classic root-owned-config-dir mess.
2670
+ *
2671
+ * The daemon runs as root for the TUN device; the Beagle chat UI runs as you.
2672
+ * Every file the daemon created under ~/.agentnet before this was fixed came
2673
+ * out root-owned, so the UI's writes into downloads/ and outbox/ fail with
2674
+ * EACCES and file sends die. New daemons repair this themselves at startup —
2675
+ * this is the command for an install that predates that, or one whose daemon
2676
+ * cannot be restarted right now.
2677
+ */
2678
+ export async function cmdFixPerms(args) {
2679
+ const { configOwner, restoreOwnership } = await import("../utils/config-owner.js");
2680
+ const dir = args.configDir || ConfigLoader.defaultConfigDir();
2681
+ if (!existsSync(dir)) {
2682
+ throw new Error(`No config dir at ${dir}. Run 'agentnet init' first, or pass --config-dir.`);
2683
+ }
2684
+ let owner = configOwner(dir);
2685
+ if (args.user) {
2686
+ // Explicit target: `sudo agentnet fix-perms --user alice`, for a dir whose
2687
+ // own ownership is already wrong (a whole tree accidentally chowned root).
2688
+ const { execFileSync } = await import("child_process");
2689
+ const uid = parseInt(execFileSync("id", ["-u", args.user], { encoding: "utf-8" }).trim(), 10);
2690
+ const gid = parseInt(execFileSync("id", ["-g", args.user], { encoding: "utf-8" }).trim(), 10);
2691
+ if (!Number.isInteger(uid) || !Number.isInteger(gid))
2692
+ throw new Error(`Unknown user: ${args.user}`);
2693
+ owner = { uid, gid };
2694
+ }
2695
+ if (!owner) {
2696
+ // Not root, or the dir genuinely belongs to root. Either way there is
2697
+ // nothing this process can chown — say which, precisely.
2698
+ if (process.getuid?.() !== 0) {
2699
+ console.log(`Nothing to do: not running as root, so every file this user creates is already theirs.`);
2700
+ console.log(`If a file send failed with EACCES, the root-owned files need root to repair:`);
2701
+ console.log(` sudo agentnet fix-perms`);
2702
+ return;
2703
+ }
2704
+ console.log(`Nothing to do: ${dir} belongs to root and no invoking user was found.`);
2705
+ console.log(`If this node should belong to a normal user, name them: sudo agentnet fix-perms --user <name>`);
2706
+ return;
2707
+ }
2708
+ const changed = await restoreOwnership(dir, owner);
2709
+ if (changed === 0) {
2710
+ console.log(`Already correct: everything under ${dir} belongs to uid ${owner.uid}.`);
2711
+ return;
2712
+ }
2713
+ console.log(`Fixed ${changed} path(s) under ${dir} → uid ${owner.uid}:${owner.gid}.`);
2714
+ console.log(`File sends from the Beagle UI should work now — no restart needed.`);
2715
+ }
2668
2716
  export async function cmdRestart(args) {
2669
2717
  const dir = args.configDir || ConfigLoader.defaultConfigDir();
2670
2718
  const config = await ConfigLoader.load(resolve(dir, "config.yaml"));
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, cmdFriendsPending, cmdFriendsAccept, cmdFriendsAutoAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyWho, cmdProxyAccess, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyWhitelist, cmdProxyFileWhitelist, cmdProxyBusyMbps, cmdProxyUse, cmdProxyRouter, cmdProxyTrustCa, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdBootstrapShow, cmdBootstrapUpdate, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdHardenSsh, cmdServiceInstall, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdConsole, cmdFileSend, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
13
+ import { cmdInit, cmdIdentityShow, cmdPeersList, cmdIpamAssign, cmdGrant, cmdRevoke, cmdResolve, cmdStatus, cmdUp, cmdAuditLog, cmdFriendRequest, cmdFriendAccept, cmdFriendsList, cmdFriendsPending, cmdFriendsAccept, cmdFriendsAutoAccept, cmdFriendsReject, cmdProxyEnable, cmdProxyDisable, cmdProxyStatus, cmdProxyWho, cmdProxyAccess, cmdProxyAllowHost, cmdProxyRevokeHost, cmdProxyListHosts, cmdProxyWhitelist, cmdProxyFileWhitelist, cmdProxyBusyMbps, cmdProxyUse, cmdProxyRouter, cmdProxyTrustCa, cmdDoraEnable, cmdDoraDisable, cmdDoraStatus, cmdDoraAutofriend, cmdBootstrapShow, cmdBootstrapUpdate, cmdDiag, cmdDoctor, cmdDnsInstall, cmdDnsHosts, cmdHardenSsh, cmdServiceInstall, cmdFixPerms, cmdRestart, cmdServiceStatus, cmdServiceRestart, cmdUi, cmdConsole, cmdFileSend, cmdChatSend, cmdChatHistory, cmdFriendRemove, cmdFriendAlias, } from "./commands.js";
14
14
  async function main() {
15
15
  await yargs(hideBin(process.argv))
16
16
  .scriptName("agentnet")
@@ -203,6 +203,11 @@ async function main() {
203
203
  throw new Error("path is required: agentnet file send <userid> <path>");
204
204
  await cmdFileSend({ to: argv.userid, path: argv.path, configDir: argv["config-dir"] });
205
205
  }
206
+ })
207
+ .command("fix-perms", "Repair root-owned files under ~/.agentnet (run with sudo) — fixes 'file send failed' EACCES", (y) => y
208
+ .option("config-dir", { type: "string" })
209
+ .option("user", { type: "string", describe: "Owner to restore to (default: the config dir's own user)" }), async (argv) => {
210
+ await cmdFixPerms({ configDir: argv["config-dir"], user: argv.user });
206
211
  })
207
212
  // Tell the running daemon to re-exec itself with its original argv.
208
213
  // The daemon inherits its own uid (root if it was launched as root)
@@ -289,6 +289,20 @@ export class DaemonServer {
289
289
  catch (err) {
290
290
  this.logger.warn(`Could not write pidfile ${this.pidFile}: ${err}`);
291
291
  }
292
+ // We are probably root (TUN needs it) while the config dir belongs to a
293
+ // normal user, and unprivileged apps — the Beagle chat UI above all — write
294
+ // into downloads/ and outbox/. Anything root left behind there would fail
295
+ // their writes with EACCES, so hand it back before we create anything new.
296
+ try {
297
+ const { restoreConfigDirOwnership } = await import("../utils/config-owner.js");
298
+ const fixed = await restoreConfigDirOwnership(this.configDir);
299
+ if (fixed > 0) {
300
+ this.logger.info(`Restored ownership of ${fixed} path(s) under ${this.configDir} to the config dir's user`);
301
+ }
302
+ }
303
+ catch (err) {
304
+ this.logger.warn(`Could not restore config dir ownership: ${err.message}`);
305
+ }
292
306
  this.logger.info(`Starting daemon (node: ${this.config.node.name})`);
293
307
  this.startedAt = Date.now();
294
308
  try {
@@ -564,12 +578,35 @@ export class DaemonServer {
564
578
  throw new Error(`This friend is a native (iOS/Android) client — files are sent inline and limited to ` +
565
579
  `${(INLINE_MAX / 1024 / 1024).toFixed(1)} MB (this is ${(data.length / 1024 / 1024).toFixed(1)} MB).`);
566
580
  }
567
- await this.peerManager.sendInlineFile(userid, new Uint8Array(data), name);
568
- this.logger.info(`Sent inline file "${name}" (${data.length}B) to native peer ${userid.slice(0, 8)}`);
569
- this.messageStore?.appendFile(userid, "out", { name: sanitizeFileName(name), size: data.length, status: "sent", sent: data.length });
581
+ // Append the chip as "sending" FIRST, then let the SDK's delivery
582
+ // verdict decide what it becomes. Marking "sent" the moment
583
+ // sendInlineFile resolved was a lie — that only meant the bytes
584
+ // left this machine; a half-open session swallowed them silently
585
+ // while the sender stared at a checkmark (INBOX 2026-08-21 A).
586
+ const safeName = sanitizeFileName(name);
587
+ const chip = this.messageStore?.appendFile(userid, "out", { name: safeName, size: data.length, status: "sending", sent: 0 });
570
588
  this.friendMeta?.ensure(userid);
571
589
  this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
572
- return { inline: true, name, size: data.length };
590
+ let delivery;
591
+ try {
592
+ delivery = await this.peerManager.sendInlineFile(userid, new Uint8Array(data), name);
593
+ }
594
+ catch (e) {
595
+ if (chip)
596
+ this.messageStore?.patchFile(userid, chip.id, { status: "failed" });
597
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
598
+ throw e;
599
+ }
600
+ const status = delivery === "acked" ? "sent" : delivery === "offline" ? "queued" : "failed";
601
+ if (chip)
602
+ this.messageStore?.patchFile(userid, chip.id, { status, sent: delivery === "acked" ? data.length : 0 });
603
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
604
+ this.logger.info(`Inline file "${name}" (${data.length}B) to native ${userid.slice(0, 8)}: delivery=${delivery} → ${status}`);
605
+ if (delivery === "accepted") {
606
+ throw new Error(`The peer never confirmed receiving "${name}" — it likely did not arrive. ` +
607
+ `Check that their app is in the foreground and try again.`);
608
+ }
609
+ return { inline: true, name, size: data.length, delivery };
573
610
  }
574
611
  // Add the "out" chip immediately as STATUS=sending (not "sent" — the
575
612
  // transfer is reliable+acked, so it only flips to "sent" once the
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.277";
1
+ window.__DK_UI_VERSION="0.1.279";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Ownership of the config dir when the daemon runs as root.
3
+ *
4
+ * The daemon needs root for the TUN device, but `~/.agentnet` belongs to a
5
+ * normal user — and so do the other programs that read and write it. The Beagle
6
+ * chat UI runs unprivileged and writes uploads into `downloads/`, queues into
7
+ * `outbox/`, and appends to the message store; every file root creates there
8
+ * comes out root-owned, so those writes fail with EACCES and a file send dies
9
+ * with nothing but a permission error in a log the user never sees.
10
+ *
11
+ * So: hand ownership of everything under the config dir back to the user who
12
+ * owns the dir itself. Best-effort throughout — a failure here must never stop
13
+ * the daemon from starting, it just leaves the user where they were.
14
+ */
15
+ export interface ConfigOwner {
16
+ uid: number;
17
+ gid: number;
18
+ }
19
+ /**
20
+ * The uid/gid that SHOULD own the config dir, or null when there is nothing to
21
+ * do (not root, or the dir is already root's own — a genuinely root-run node).
22
+ *
23
+ * `SUDO_UID` covers `sudo agentnet up`; the config dir's own owner covers a
24
+ * daemon started by launchd/systemd, where sudo never set anything.
25
+ */
26
+ export declare function configOwner(dir: string): ConfigOwner | null;
27
+ /**
28
+ * Recursively hand `dir` to `owner`, touching only the paths that are actually
29
+ * wrong. A full walk of the config dir costs a readdir per directory — cheap
30
+ * enough to run on every daemon start, and the steady state changes nothing.
31
+ *
32
+ * Note we cannot skip a correctly-owned directory: the daemon writing into a
33
+ * user-created `downloads/` produces exactly that shape — right dir, wrong
34
+ * files inside.
35
+ */
36
+ export declare function restoreOwnership(dir: string, owner: ConfigOwner): Promise<number>;
37
+ /**
38
+ * Startup sweep: if we are root and the config dir belongs to someone else,
39
+ * give everything under it back to them. Returns how many paths changed (0 when
40
+ * there was nothing to do, which is the steady state).
41
+ */
42
+ export declare function restoreConfigDirOwnership(dir: string): Promise<number>;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Ownership of the config dir when the daemon runs as root.
3
+ *
4
+ * The daemon needs root for the TUN device, but `~/.agentnet` belongs to a
5
+ * normal user — and so do the other programs that read and write it. The Beagle
6
+ * chat UI runs unprivileged and writes uploads into `downloads/`, queues into
7
+ * `outbox/`, and appends to the message store; every file root creates there
8
+ * comes out root-owned, so those writes fail with EACCES and a file send dies
9
+ * with nothing but a permission error in a log the user never sees.
10
+ *
11
+ * So: hand ownership of everything under the config dir back to the user who
12
+ * owns the dir itself. Best-effort throughout — a failure here must never stop
13
+ * the daemon from starting, it just leaves the user where they were.
14
+ */
15
+ import { lchown, lstat, readdir } from "fs/promises";
16
+ import { statSync } from "fs";
17
+ import { join } from "path";
18
+ /**
19
+ * The uid/gid that SHOULD own the config dir, or null when there is nothing to
20
+ * do (not root, or the dir is already root's own — a genuinely root-run node).
21
+ *
22
+ * `SUDO_UID` covers `sudo agentnet up`; the config dir's own owner covers a
23
+ * daemon started by launchd/systemd, where sudo never set anything.
24
+ */
25
+ export function configOwner(dir) {
26
+ if (process.getuid?.() !== 0)
27
+ return null; // not root: whatever we create is already ours
28
+ const sudoUid = Number(process.env.SUDO_UID);
29
+ const sudoGid = Number(process.env.SUDO_GID);
30
+ if (Number.isInteger(sudoUid) && sudoUid > 0) {
31
+ return { uid: sudoUid, gid: Number.isInteger(sudoGid) && sudoGid >= 0 ? sudoGid : sudoUid };
32
+ }
33
+ try {
34
+ const st = statSync(dir);
35
+ if (st.uid > 0)
36
+ return { uid: st.uid, gid: st.gid };
37
+ }
38
+ catch {
39
+ // No dir yet — nothing to own.
40
+ }
41
+ return null;
42
+ }
43
+ /** Give one path to `owner` if it isn't already. Returns true when changed. */
44
+ async function chownOne(path, owner) {
45
+ try {
46
+ const st = await lstat(path);
47
+ if (st.uid === owner.uid && st.gid === owner.gid)
48
+ return false;
49
+ // lchown, not chown: a symlink inside the config dir must not let us
50
+ // retarget ownership of whatever it points at.
51
+ await lchown(path, owner.uid, owner.gid);
52
+ return true;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ /**
59
+ * Recursively hand `dir` to `owner`, touching only the paths that are actually
60
+ * wrong. A full walk of the config dir costs a readdir per directory — cheap
61
+ * enough to run on every daemon start, and the steady state changes nothing.
62
+ *
63
+ * Note we cannot skip a correctly-owned directory: the daemon writing into a
64
+ * user-created `downloads/` produces exactly that shape — right dir, wrong
65
+ * files inside.
66
+ */
67
+ export async function restoreOwnership(dir, owner) {
68
+ let changed = 0;
69
+ const walk = async (path, depth) => {
70
+ if (depth > 12)
71
+ return; // pathological nesting / symlink loop guard
72
+ let entries;
73
+ try {
74
+ entries = await readdir(path, { withFileTypes: true });
75
+ }
76
+ catch {
77
+ return; // unreadable or vanished — nothing we can fix here
78
+ }
79
+ for (const entry of entries) {
80
+ const child = join(path, entry.name);
81
+ if (await chownOne(child, owner))
82
+ changed++;
83
+ // Directories only: never follow a symlink out of the config dir.
84
+ if (entry.isDirectory())
85
+ await walk(child, depth + 1);
86
+ }
87
+ };
88
+ if (await chownOne(dir, owner))
89
+ changed++;
90
+ await walk(dir, 0);
91
+ return changed;
92
+ }
93
+ /**
94
+ * Startup sweep: if we are root and the config dir belongs to someone else,
95
+ * give everything under it back to them. Returns how many paths changed (0 when
96
+ * there was nothing to do, which is the steady state).
97
+ */
98
+ export async function restoreConfigDirOwnership(dir) {
99
+ const owner = configOwner(dir);
100
+ if (!owner)
101
+ return 0;
102
+ return restoreOwnership(dir, owner);
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.277",
3
+ "version": "0.1.279",
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",
@@ -84,7 +84,7 @@
84
84
  },
85
85
  "dependencies": {
86
86
  "@decentnetwork/dora": "^0.1.14",
87
- "@decentnetwork/peer": "^0.1.139",
87
+ "@decentnetwork/peer": "^0.1.140",
88
88
  "@decentnetwork/peer-webrtc": "^0.2.10",
89
89
  "ink": "^5.2.1",
90
90
  "js-yaml": "^4.1.0",