diggylab 0.1.35 → 0.1.39

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 (3) hide show
  1. package/README.md +57 -18
  2. package/cli.js +437 -98
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -7,8 +7,8 @@ headless Chromium — the Electron app is not involved.
7
7
  ## Requirements
8
8
 
9
9
  - Node.js 20+
10
- - Linux packages: `xvfb`, `x11vnc`, `websockify`, `novnc` (Debian/Ubuntu: `apt install xvfb x11vnc websockify novnc`). The Docker image below bundles them.
11
- - Chromium for Playwright: run `diggylab install-browser` once after installing the CLI.
10
+ - Linux packages: `xvfb` and `x11vnc` — run `diggylab install-display` (Debian/Ubuntu) or install them yourself. The Docker image below bundles them.
11
+ - Chromium for Playwright: downloaded automatically during `npm install`; rerun `diggylab install-browser` if that download failed.
12
12
 
13
13
  ## Install
14
14
 
@@ -58,36 +58,75 @@ npm publish apps/desktop-agent/dist-npm --access public
58
58
  | Command | What it does |
59
59
  | --- | --- |
60
60
  | `pair --code <code> [--name <device name>] [--api <origin>]` | Pairs the server as a device. Create the code on `/settings/desktop-agent` in the web app. |
61
- | `login` | Opens the Facebook login page on a virtual screen and prints a noVNC link + QR code. Log in from your phone; the session is stored encrypted on the server. |
61
+ | `login` | Renders the Facebook login page on a virtual screen and prints a DiggyLab link + QR code. Open it on any phone or laptop — no ports, no tunnel — and the session is stored encrypted on the server. |
62
62
  | `run` | Connects the WebSocket worker, receives assigned scans, scans headlessly, uploads results. |
63
63
  | `status` | Shows the config directory, API origin, paired device, and Facebook session state. |
64
- | `install-browser` | Downloads the Chromium build Playwright needs (one time, ~150 MB). Run it once after installing the CLI. |
64
+ | `install-browser` | Downloads the Chromium build Playwright needs (~150 MB). It also runs during `npm install`, so you only need it if that download failed. |
65
+ | `install-display` | On Debian/Ubuntu, installs `xvfb` and `x11vnc` for the login screen. |
65
66
  | `unpair` | Removes device credentials, the Facebook session, and pending scans. |
66
67
 
67
- Useful options: `--api <origin>` (default `https://api.diggylab.com`), `--port` / `--bind` /
68
- `--public-host` for the login screen, `--display <number>` to avoid an Xvfb collision.
68
+ Useful options: `--api <origin>` (default `https://api.diggylab.com`), `--web-url <origin>` (only for a
69
+ self-hosted web app, or `DIGGYLAB_WEB_URL`), `--display <number>` to avoid an Xvfb collision.
69
70
 
70
71
  ## How `login` works on a server
71
72
 
72
- A server has no screen, so the agent starts `Xvfb` (virtual screen), `x11vnc` (shares it) and
73
- `websockify` + noVNC (turns it into a web page):
73
+ A server has no screen, so the agent starts `Xvfb` (virtual screen) and `x11vnc` (shares it), then
74
+ carries that screen to the customer through the API connection it already uses for scans:
74
75
 
75
76
  ```
76
- Xvfb → Chromium opens Facebook's real login page → noVNC link + QR → phone logs in → session saved
77
+ Xvfb → Chromium opens Facebook's real login page → encrypted over the API → link + QR → phone logs in
77
78
  ```
78
79
 
79
- The noVNC URL carries a one-time token, the port is only open while `login` runs, and everything is
80
- torn down when the login finishes or times out (10 minutes). If you prefer not to expose a port,
81
- pass `--bind 127.0.0.1` and forward it over SSH (`ssh -L 6080:127.0.0.1:6080 <server>`).
80
+ Nothing has to be opened on the server: no port, no tunnel, no firewall change. The link points at
81
+ the web app (`https://diggylab.com/agent-login/<session>#<key>`), and everything is torn down when the
82
+ login finishes or times out (10 minutes).
82
83
 
83
- ### Which address the link uses
84
+ ### The screen is encrypted end to end
84
85
 
85
- The link points at the machine's default-route address, so a multi-homed server advertises the
86
- network it actually reaches the internet through. When that address is private, the agent also looks
87
- up the public IP and prints a second link for phones on another network — forward the port first.
86
+ The part of the link after `#` is the decryption key, and browsers never send a fragment to a server —
87
+ so the relay passes bytes it cannot read. DiggyLab stores nothing: no frames, no keystrokes, no
88
+ session. The Facebook cookies stay on the customer's server, sealed with `~/.diggylab/key`.
88
89
 
89
- Pin it explicitly with `--public-host <ip or domain>` or `DIGGYLAB_PUBLIC_HOST`, for example when a
90
- domain points at the server.
90
+ The link itself is the only way in: it is single use, expires with the login window, and the session
91
+ is created per `login` run.
92
+
93
+ ### When the screen does not appear
94
+
95
+ `login` checks the virtual screen before printing the link: it reads x11vnc's greeting, so a broken
96
+ screen stops the command with the reason instead of leaving the customer with a blank page.
97
+
98
+ Each process writes a log to `~/.diggylab/logs/` (`xvfb.log`, `x11vnc.log`). The tail is printed when
99
+ the screen fails or a process dies during the login, and the files stay there for later inspection.
100
+
101
+ Manual checks while `login` is running:
102
+
103
+ ```bash
104
+ pgrep -a Xvfb; pgrep -a x11vnc # both must be running
105
+ ss -ltnp | grep :5999 # x11vnc listens on loopback
106
+ timeout 2 bash -c 'exec 3<>/dev/tcp/127.0.0.1/5999 && head -c 12 <&3' # expect: RFB 003.008
107
+ ```
108
+
109
+ If the browser says the link expired, run `diggylab login` again — each run mints a new session.
110
+
111
+ ### Logging in with cookies from your own browser
112
+
113
+ If you would rather not share a screen, log into Facebook in your own browser and import those
114
+ cookies instead — no Xvfb, no link, no port:
115
+
116
+ 1. Install **Cookie-Editor** (Chrome, Edge, Firefox), open `facebook.com`, then *Export → JSON*.
117
+ (Or copy the `cookie:` request header from your browser's DevTools.)
118
+ 2. On the server:
119
+
120
+ ```bash
121
+ diggylab login --cookies cookies.json # or: diggylab login --cookies - and paste, then Ctrl-D
122
+ ```
123
+
124
+ The agent keeps only the `facebook.com` cookies, saves them encrypted, and opens Facebook once to
125
+ confirm the session before it accepts it. Cookies exported from a different account, or expired ones,
126
+ are rejected with a message instead of failing during a scan.
127
+
128
+ Note: Facebook sometimes asks for a security check when a session moves to a server's IP. If that
129
+ happens the command says so — approve the login from your phone and import again.
91
130
 
92
131
  ## Security notes
93
132
 
package/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/cli/cli.ts
4
4
  import { randomUUID as randomUUID3 } from "node:crypto";
5
5
  import { spawn as spawn2 } from "node:child_process";
6
- import { existsSync as existsSync2, readFileSync as readFileSync3, realpathSync } from "node:fs";
6
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, realpathSync } from "node:fs";
7
7
  import { hostname, homedir } from "node:os";
8
8
  import { dirname as dirname3, join as join4 } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
@@ -513,7 +513,7 @@ function withTimeout(promise, timeoutMs) {
513
513
 
514
514
  // src/cli/cli.ts
515
515
  import { chromium } from "playwright";
516
- import WebSocket from "ws";
516
+ import WebSocket2 from "ws";
517
517
 
518
518
  // src/main/activity-log.ts
519
519
  import { gzip } from "node:zlib";
@@ -2312,14 +2312,12 @@ function readPrivateKey(privateKey) {
2312
2312
  // src/cli/args.ts
2313
2313
  var FLAGS = {
2314
2314
  "--api": { key: "api", type: "string" },
2315
- "--bind": { key: "bind", type: "string" },
2316
2315
  "--code": { key: "code", type: "string" },
2317
2316
  "--config": { key: "config", type: "string" },
2317
+ "--cookies": { key: "cookies", type: "string" },
2318
2318
  "--display": { key: "display", type: "number" },
2319
2319
  "--name": { key: "name", type: "string" },
2320
- "--novnc-web": { key: "novncWeb", type: "string" },
2321
- "--port": { key: "port", type: "number" },
2322
- "--public-host": { key: "publicHost", type: "string" }
2320
+ "--web-url": { key: "webUrl", type: "string" }
2323
2321
  };
2324
2322
  function parseCliArgs(argv) {
2325
2323
  const options = {};
@@ -2351,6 +2349,83 @@ function parseCliArgs(argv) {
2351
2349
  return { command, options };
2352
2350
  }
2353
2351
 
2352
+ // src/cli/browser-install.ts
2353
+ function browserInstallCommands(platform) {
2354
+ const install = ["install", "chromium"];
2355
+ return platform === "linux" ? [["install", "--with-deps", "chromium"], install] : [install];
2356
+ }
2357
+
2358
+ // src/cli/cookie-import.ts
2359
+ var FACEBOOK_DOMAIN = ".facebook.com";
2360
+ var FACEBOOK_HOST = /(^|\.)facebook\.com$/i;
2361
+ function isFacebookDomain(domain) {
2362
+ return Boolean(domain && FACEBOOK_HOST.test(domain.replace(/^\./, "")));
2363
+ }
2364
+ function normalizeSameSite(value) {
2365
+ if (typeof value !== "string") return void 0;
2366
+ const lower = value.toLowerCase().replace(/[^a-z]/g, "");
2367
+ if (lower === "lax") return "Lax";
2368
+ if (lower === "strict") return "Strict";
2369
+ if (lower === "none" || lower === "norestriction") return "None";
2370
+ return void 0;
2371
+ }
2372
+ function toCookie(value) {
2373
+ const name = typeof value.name === "string" ? value.name.trim() : "";
2374
+ const cookieValue = typeof value.value === "string" ? value.value : "";
2375
+ if (!name || !cookieValue) return null;
2376
+ return {
2377
+ domain: typeof value.domain === "string" ? value.domain : void 0,
2378
+ expires: typeof value.expires === "number" ? value.expires : typeof value.expirationDate === "number" ? value.expirationDate : void 0,
2379
+ httpOnly: typeof value.httpOnly === "boolean" ? value.httpOnly : void 0,
2380
+ name,
2381
+ path: typeof value.path === "string" ? value.path : void 0,
2382
+ sameSite: normalizeSameSite(value.sameSite),
2383
+ secure: typeof value.secure === "boolean" ? value.secure : void 0,
2384
+ value: cookieValue
2385
+ };
2386
+ }
2387
+ function parseCookieHeader(input) {
2388
+ const header = input.replace(/^\s*cookie\s*:/i, "");
2389
+ return header.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
2390
+ const separator = part.indexOf("=");
2391
+ if (separator <= 0) return null;
2392
+ return { name: part.slice(0, separator).trim(), value: part.slice(separator + 1) };
2393
+ }).filter((cookie) => cookie !== null);
2394
+ }
2395
+ function parseCookieInput(input) {
2396
+ const trimmed = input.trim();
2397
+ if (!trimmed) return [];
2398
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return parseCookieHeader(trimmed);
2399
+ try {
2400
+ const parsed = JSON.parse(trimmed);
2401
+ if (Array.isArray(parsed)) {
2402
+ return parsed.map((entry) => entry && typeof entry === "object" ? toCookie(entry) : null).filter((cookie) => cookie !== null);
2403
+ }
2404
+ if (parsed && typeof parsed === "object") {
2405
+ const cookies = parsed.cookies;
2406
+ if (Array.isArray(cookies)) {
2407
+ return cookies.map((entry) => entry && typeof entry === "object" ? toCookie(entry) : null).filter((cookie) => cookie !== null);
2408
+ }
2409
+ }
2410
+ } catch {
2411
+ return [];
2412
+ }
2413
+ return [];
2414
+ }
2415
+ function toFacebookStorageState(cookies) {
2416
+ const usable = cookies.filter((cookie) => isFacebookDomain(cookie.domain) || cookie.domain === void 0).map((cookie) => ({
2417
+ domain: isFacebookDomain(cookie.domain) ? cookie.domain : FACEBOOK_DOMAIN,
2418
+ expires: typeof cookie.expires === "number" && cookie.expires > 0 ? cookie.expires : -1,
2419
+ httpOnly: cookie.httpOnly ?? true,
2420
+ name: cookie.name,
2421
+ path: cookie.path?.startsWith("/") ? cookie.path : "/",
2422
+ sameSite: cookie.sameSite ?? "Lax",
2423
+ secure: cookie.secure ?? true,
2424
+ value: cookie.value
2425
+ }));
2426
+ return { cookies: usable, origins: [] };
2427
+ }
2428
+
2354
2429
  // src/cli/file-crypto.ts
2355
2430
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2356
2431
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -2398,6 +2473,163 @@ function createFileCrypto(keyPath) {
2398
2473
  };
2399
2474
  }
2400
2475
 
2476
+ // src/cli/login-screen-relay.ts
2477
+ import { createConnection } from "node:net";
2478
+ import WebSocket from "ws";
2479
+
2480
+ // src/cli/relay-crypto.ts
2481
+ import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, hkdfSync, randomBytes as randomBytes2 } from "node:crypto";
2482
+ var FRAME_VERSION = 1;
2483
+ var IV_BYTES2 = 12;
2484
+ var KEY_BYTES2 = 32;
2485
+ var TAG_BYTES2 = 16;
2486
+ function createRelayKey() {
2487
+ return randomBytes2(KEY_BYTES2).toString("base64url");
2488
+ }
2489
+ function deriveRelayKeys(key, sessionId) {
2490
+ const secret = Buffer.from(key, "base64url");
2491
+ const salt = Buffer.from(sessionId, "utf8");
2492
+ const derive = (info) => Buffer.from(hkdfSync("sha256", secret, salt, Buffer.from(info, "utf8"), KEY_BYTES2));
2493
+ return { fromAgent: derive("agent-to-browser"), toAgent: derive("browser-to-agent") };
2494
+ }
2495
+ function sealFrame(key, payload) {
2496
+ const iv = randomBytes2(IV_BYTES2);
2497
+ const cipher = createCipheriv2("aes-256-gcm", key, iv);
2498
+ const sealed = Buffer.concat([cipher.update(payload), cipher.final()]);
2499
+ return Buffer.concat([Buffer.from([FRAME_VERSION]), iv, sealed, cipher.getAuthTag()]);
2500
+ }
2501
+ function openFrame(key, frame) {
2502
+ if (frame.length < 1 + IV_BYTES2 + TAG_BYTES2 || frame[0] !== FRAME_VERSION) {
2503
+ throw new Error("Invalid login screen frame.");
2504
+ }
2505
+ const decipher = createDecipheriv2("aes-256-gcm", key, frame.subarray(1, 1 + IV_BYTES2));
2506
+ decipher.setAuthTag(frame.subarray(frame.length - TAG_BYTES2));
2507
+ return Buffer.concat([
2508
+ decipher.update(frame.subarray(1 + IV_BYTES2, frame.length - TAG_BYTES2)),
2509
+ decipher.final()
2510
+ ]);
2511
+ }
2512
+
2513
+ // src/cli/login-screen-relay.ts
2514
+ var RELAY_PATH = "/desktop-agent/login-screen";
2515
+ var READY_TIMEOUT_MS = 1e4;
2516
+ var DEFAULT_WEB_ORIGIN = "https://diggylab.com";
2517
+ async function openLoginScreenRelay(options) {
2518
+ const socket = new WebSocket(`${toWebSocketOrigin(options.apiOrigin)}${RELAY_PATH}`, {
2519
+ headers: { authorization: `Bearer ${options.accessToken}` }
2520
+ });
2521
+ let closed = false;
2522
+ let keys = null;
2523
+ let vnc = null;
2524
+ let resolveScreen = () => void 0;
2525
+ let rejectFailure = () => void 0;
2526
+ const failure = new Promise((_resolve, reject) => {
2527
+ rejectFailure = reject;
2528
+ });
2529
+ const screenConnected = new Promise((resolve) => {
2530
+ resolveScreen = resolve;
2531
+ });
2532
+ failure.catch(() => void 0);
2533
+ const close = () => {
2534
+ closed = true;
2535
+ vnc?.destroy();
2536
+ vnc = null;
2537
+ if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close();
2538
+ };
2539
+ const fail = (message) => {
2540
+ close();
2541
+ rejectFailure(new Error(message));
2542
+ };
2543
+ const openScreen = () => {
2544
+ if (closed || vnc) return;
2545
+ const connection = createConnection({ host: "127.0.0.1", port: options.rfbPort });
2546
+ vnc = connection;
2547
+ connection.on("data", (chunk) => {
2548
+ if (closed || !keys || socket.readyState !== WebSocket.OPEN) return;
2549
+ socket.send(sealFrame(keys.fromAgent, chunk), { binary: true });
2550
+ });
2551
+ connection.once("error", () => {
2552
+ if (vnc === connection) vnc = null;
2553
+ fail("Could not reach the virtual screen.");
2554
+ });
2555
+ connection.once("close", () => {
2556
+ if (vnc === connection) vnc = null;
2557
+ });
2558
+ };
2559
+ socket.on("message", (data, isBinary) => {
2560
+ if (isBinary) {
2561
+ if (!keys || !vnc || vnc.destroyed) return;
2562
+ try {
2563
+ vnc.write(openFrame(keys.toAgent, Buffer.from(data)));
2564
+ } catch {
2565
+ fail("The login screen received a corrupted frame.");
2566
+ }
2567
+ return;
2568
+ }
2569
+ const message = parseRelayMessage(data.toString());
2570
+ if (message?.type === "screen-connected") {
2571
+ openScreen();
2572
+ resolveScreen();
2573
+ } else if (message?.type === "screen-disconnected") {
2574
+ vnc?.destroy();
2575
+ vnc = null;
2576
+ }
2577
+ });
2578
+ socket.once("error", () => fail("Could not reach the DiggyLab relay."));
2579
+ socket.once("close", () => {
2580
+ if (!closed) fail("The DiggyLab relay closed the login screen.");
2581
+ });
2582
+ const ready = await waitForReady(socket);
2583
+ keys = deriveRelayKeys(options.key, ready.id);
2584
+ const webOrigin = (options.webUrl ?? ready.webUrl ?? DEFAULT_WEB_ORIGIN).replace(/\/+$/, "");
2585
+ return {
2586
+ close,
2587
+ failure,
2588
+ screenConnected,
2589
+ url: `${webOrigin}/agent-login/${encodeURIComponent(ready.id)}#${options.key}`
2590
+ };
2591
+ }
2592
+ function parseRelayMessage(value) {
2593
+ try {
2594
+ const parsed = JSON.parse(value);
2595
+ if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
2596
+ return parsed;
2597
+ }
2598
+ } catch {
2599
+ }
2600
+ return null;
2601
+ }
2602
+ function waitForReady(socket) {
2603
+ return new Promise((resolve, reject) => {
2604
+ const timer = setTimeout(() => reject(new Error("The DiggyLab relay did not answer in time.")), READY_TIMEOUT_MS);
2605
+ timer.unref();
2606
+ socket.once("message", (data, isBinary) => {
2607
+ clearTimeout(timer);
2608
+ if (isBinary) {
2609
+ reject(new Error("The DiggyLab relay answered with an unexpected frame."));
2610
+ return;
2611
+ }
2612
+ const message = parseRelayMessage(data.toString());
2613
+ if (message?.type !== "ready") {
2614
+ reject(new Error("The DiggyLab relay did not accept the login screen."));
2615
+ return;
2616
+ }
2617
+ resolve(message);
2618
+ });
2619
+ socket.once("error", () => {
2620
+ clearTimeout(timer);
2621
+ reject(new Error("Could not reach the DiggyLab relay."));
2622
+ });
2623
+ socket.once("close", () => {
2624
+ clearTimeout(timer);
2625
+ reject(new Error("The DiggyLab relay refused the login screen."));
2626
+ });
2627
+ });
2628
+ }
2629
+ function toWebSocketOrigin(origin) {
2630
+ return origin.replace(/^http/i, "ws").replace(/\/+$/, "");
2631
+ }
2632
+
2401
2633
  // src/cli/qr.ts
2402
2634
  import qrcode from "qrcode-terminal";
2403
2635
  function terminalQr(value) {
@@ -2410,19 +2642,41 @@ function terminalQr(value) {
2410
2642
 
2411
2643
  // src/cli/virtual-display.ts
2412
2644
  import { spawn } from "node:child_process";
2413
- import { randomBytes as randomBytes2 } from "node:crypto";
2414
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
2415
- import { createConnection } from "node:net";
2416
- import { networkInterfaces } from "node:os";
2645
+ import { createWriteStream, mkdirSync as mkdirSync2 } from "node:fs";
2646
+ import { createConnection as createConnection3 } from "node:net";
2417
2647
  import { join as join3 } from "node:path";
2648
+
2649
+ // src/cli/vnc-probe.ts
2650
+ import { createConnection as createConnection2 } from "node:net";
2651
+ var PROBE_TIMEOUT_MS = 4e3;
2652
+ var GREETING_BYTES = 12;
2653
+ var GREETING_PATTERN = /^RFB \d{3}\.\d{3}\n$/;
2654
+ function probeRfb(host, port, timeoutMs = PROBE_TIMEOUT_MS) {
2655
+ return new Promise((resolve) => {
2656
+ const socket = createConnection2({ host, port });
2657
+ const finish = (result) => {
2658
+ clearTimeout(timer);
2659
+ socket.destroy();
2660
+ resolve(result);
2661
+ };
2662
+ const timer = setTimeout(() => finish(false), timeoutMs);
2663
+ let received = "";
2664
+ socket.on("data", (chunk) => {
2665
+ received += chunk.toString("latin1");
2666
+ if (received.length >= GREETING_BYTES) finish(GREETING_PATTERN.test(received.slice(0, GREETING_BYTES)));
2667
+ });
2668
+ socket.once("error", () => finish(false));
2669
+ socket.once("close", () => finish(false));
2670
+ });
2671
+ }
2672
+
2673
+ // src/cli/virtual-display.ts
2418
2674
  var START_TIMEOUT_MS = 15e3;
2419
- var PUBLIC_IP_TIMEOUT_MS = 3e3;
2420
- var PUBLIC_IP_ENDPOINT = "https://api.ipify.org";
2421
2675
  function waitForPort(host, port, timeoutMs) {
2422
2676
  const deadline = Date.now() + timeoutMs;
2423
2677
  return new Promise((resolve, reject) => {
2424
2678
  const attempt = () => {
2425
- const socket = createConnection({ host, port });
2679
+ const socket = createConnection3({ host, port });
2426
2680
  socket.once("connect", () => {
2427
2681
  socket.destroy();
2428
2682
  resolve();
@@ -2439,94 +2693,56 @@ function waitForPort(host, port, timeoutMs) {
2439
2693
  attempt();
2440
2694
  });
2441
2695
  }
2442
- function isPrivateAddress(address) {
2443
- const [first, second] = address.split(".").map(Number);
2444
- return first === 10 || first === 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 100 && second >= 64 && second <= 127;
2445
- }
2446
- function defaultRouteInterface() {
2447
- if (process.platform !== "linux") return null;
2448
- try {
2449
- for (const line of readFileSync2("/proc/net/route", "utf8").split("\n").slice(1)) {
2450
- const [iface, destination, , , , , flags] = line.trim().split(/\s+/);
2451
- if (destination === "00000000" && Number.parseInt(flags ?? "0", 16) & 2) return iface;
2452
- }
2453
- } catch {
2454
- }
2455
- return null;
2456
- }
2457
- function localAddress() {
2458
- const interfaces = networkInterfaces();
2459
- const addressOf = (addresses) => addresses?.find((entry) => entry.family === "IPv4" && !entry.internal)?.address ?? null;
2460
- const preferred = defaultRouteInterface();
2461
- return (preferred ? addressOf(interfaces[preferred]) : null) ?? Object.values(interfaces).map(addressOf).find((address) => address !== null) ?? "127.0.0.1";
2462
- }
2463
- async function detectPublicIp(fetcher = fetch) {
2464
- try {
2465
- const response = await fetcher(PUBLIC_IP_ENDPOINT, { signal: AbortSignal.timeout(PUBLIC_IP_TIMEOUT_MS) });
2466
- if (!response.ok) return null;
2467
- const value = (await response.text()).trim();
2468
- return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) ? value : null;
2469
- } catch {
2470
- return null;
2471
- }
2472
- }
2473
- function start(command, args, env, deps) {
2696
+ function start(name, command, args, env, deps, logDirectory) {
2474
2697
  const child = deps.spawnProcess(command, args, env);
2698
+ const log = createWriteStream(join3(logDirectory, `${name}.log`), { mode: 384 });
2475
2699
  const failure = new Promise((_resolve, reject) => {
2476
2700
  child.on("error", (error) => reject(error instanceof Error ? error : new Error(String(error))));
2477
2701
  child.on("exit", (code) => reject(new Error(`${command} exited with code ${String(code)}.`)));
2478
2702
  });
2703
+ log.on("error", () => void 0);
2704
+ log.write(`# ${command} ${args.join(" ")}
2705
+ `);
2706
+ child.stdout?.pipe?.(log, { end: false });
2707
+ child.stderr?.pipe?.(log, { end: false });
2479
2708
  return { child, failure };
2480
2709
  }
2481
2710
  async function startVirtualDisplay(options, overrides = {}) {
2482
2711
  const deps = {
2483
- detectPublicIp: () => detectPublicIp(),
2484
- localAddress,
2712
+ probeRfb,
2485
2713
  spawnProcess: spawn,
2486
2714
  waitForPort,
2487
2715
  ...overrides
2488
2716
  };
2489
- const bind = options.bind ?? "0.0.0.0";
2490
2717
  const displayNumber = options.displayNumber ?? 99;
2718
+ const display = `:${displayNumber}`;
2491
2719
  const height = options.height ?? 900;
2492
- const novncWeb = options.novncWeb ?? "/usr/share/novnc";
2493
- const port = options.port ?? 6080;
2494
2720
  const rfbPort = 5900 + displayNumber;
2495
- const token = randomBytes2(18).toString("base64url");
2496
- const tokenFile = join3(options.tokenDirectory, "websockify-token");
2497
- mkdirSync2(options.tokenDirectory, { mode: 448, recursive: true });
2498
- writeFileSync2(tokenFile, `${token}: 127.0.0.1:${rfbPort}
2499
- `, { mode: 384 });
2500
- const xvfb = start("Xvfb", [`:${displayNumber}`, "-screen", `0`, `${options.width ?? 1280}x${height}x24`, "-nolisten", "tcp"], process.env, deps);
2501
- const vnc = start("x11vnc", ["-display", `:${displayNumber}`, "-localhost", "-rfbport", String(rfbPort), "-forever", "-shared", "-nopw", "-quiet"], process.env, deps);
2502
- const proxy = start("websockify", ["--web", novncWeb, "--token-plugin", "TokenFile", "--token-source", tokenFile, `${bind}:${port}`], process.env, deps);
2721
+ mkdirSync2(options.logDirectory, { mode: 448, recursive: true });
2722
+ const xvfb = start("xvfb", "Xvfb", [display, "-screen", "0", `${options.width ?? 1280}x${height}x24`, "-nolisten", "tcp"], process.env, deps, options.logDirectory);
2723
+ const vnc = start("x11vnc", "x11vnc", ["-display", display, "-localhost", "-rfbport", String(rfbPort), "-forever", "-shared", "-nopw", "-quiet"], process.env, deps, options.logDirectory);
2724
+ const failure = Promise.race([vnc.failure, xvfb.failure]);
2725
+ failure.catch(() => void 0);
2503
2726
  const stop = async () => {
2504
- for (const { child } of [proxy, vnc, xvfb]) {
2727
+ for (const { child } of [vnc, xvfb]) {
2505
2728
  child.kill("SIGTERM");
2506
2729
  await new Promise((resolve) => setTimeout(resolve, 200));
2507
2730
  child.kill("SIGKILL");
2508
2731
  }
2509
- rmSync(tokenFile, { force: true });
2510
2732
  };
2511
2733
  try {
2512
2734
  await Promise.race([
2513
- deps.waitForPort(bind === "0.0.0.0" ? "127.0.0.1" : bind, port, options.timeoutMs ?? START_TIMEOUT_MS),
2514
- proxy.failure,
2515
- vnc.failure,
2516
- xvfb.failure
2735
+ deps.waitForPort("127.0.0.1", rfbPort, options.timeoutMs ?? START_TIMEOUT_MS),
2736
+ failure
2517
2737
  ]);
2738
+ if (!await deps.probeRfb("127.0.0.1", rfbPort)) {
2739
+ throw new Error(`The virtual screen did not answer on 127.0.0.1:${rfbPort}.`);
2740
+ }
2518
2741
  } catch (error) {
2519
2742
  await stop();
2520
2743
  throw error;
2521
2744
  }
2522
- const publicHost = options.publicHost ?? deps.localAddress();
2523
- const link = (host) => `http://${host}:${port}/vnc.html?autoconnect=1&token=${encodeURIComponent(token)}`;
2524
- const publicIp = options.publicHost || !isPrivateAddress(publicHost) ? null : await deps.detectPublicIp();
2525
- return {
2526
- alternateUrl: publicIp && publicIp !== publicHost ? link(publicIp) : null,
2527
- stop,
2528
- url: link(publicHost)
2529
- };
2745
+ return { display, failure, logDirectory: options.logDirectory, rfbPort, stop };
2530
2746
  }
2531
2747
 
2532
2748
  // src/cli/cli.ts
@@ -2536,24 +2752,26 @@ var USAGE = `DiggyLab Desktop Agent CLI
2536
2752
 
2537
2753
  Usage:
2538
2754
  diggylab pair --code <code> [--name <device name>] [--api <https://api.diggylab.com>]
2539
- diggylab login [--port 6080] [--bind 0.0.0.0] [--public-host <ip or host>]
2755
+ diggylab login [--display <number>] [--web-url <https://diggylab.com>]
2756
+ diggylab login --cookies <file or -> (import cookies exported from your own browser)
2540
2757
  diggylab run
2541
2758
  diggylab status
2542
2759
  diggylab install-browser
2760
+ diggylab install-display
2543
2761
  diggylab unpair
2544
2762
 
2545
2763
  Global options:
2546
2764
  --config <directory> Agent state directory (default ~/.diggylab)
2547
2765
  --api <origin> API origin (default $DIGGYLAB_API_URL or https://api.diggylab.com)`;
2548
2766
  var MISSING_BROWSER_HINT = "Chromium for Playwright is not installed yet. Run `diggylab install-browser` once.";
2549
- var MISSING_DISPLAY_HINT = "The shared login screen needs Xvfb, x11vnc, websockify, and noVNC. Install them (apt install xvfb x11vnc websockify novnc) or use the Docker image.";
2767
+ var MISSING_DISPLAY_HINT = "The login screen needs Xvfb and x11vnc. Run `diggylab install-display` (Debian/Ubuntu) or use the Docker image.";
2550
2768
  function agentVersion() {
2551
2769
  let directory = fileURLToPath(new URL(".", import.meta.url));
2552
2770
  for (let depth = 0; depth < 4; depth += 1) {
2553
2771
  const manifest = join4(directory, "package.json");
2554
2772
  if (existsSync2(manifest)) {
2555
2773
  try {
2556
- const version = JSON.parse(readFileSync3(manifest, "utf8")).version;
2774
+ const version = JSON.parse(readFileSync2(manifest, "utf8")).version;
2557
2775
  if (typeof version === "string" && version) return version;
2558
2776
  } catch {
2559
2777
  }
@@ -2587,6 +2805,19 @@ function createRuntime(options) {
2587
2805
  const manager = new DeviceCredentialManager(sessions, http, createDeviceIdentity);
2588
2806
  return { activity, browserType: createBrowserLauncher(chromium), directory, endpoint, facebook, manager, sessions, spool };
2589
2807
  }
2808
+ function printDisplayLogs(directory, io, lines = 12) {
2809
+ io.error(`Login screen logs: ${directory}`);
2810
+ try {
2811
+ for (const name of readdirSync(directory).sort()) {
2812
+ if (!name.endsWith(".log")) continue;
2813
+ const tail = readFileSync2(join4(directory, name), "utf8").split("\n").filter((line) => line.trim() !== "").slice(-lines);
2814
+ if (tail.length === 0) continue;
2815
+ io.error(`--- ${name} ---`);
2816
+ for (const line of tail) io.error(line);
2817
+ }
2818
+ } catch {
2819
+ }
2820
+ }
2590
2821
  function waitForFacebookLogin(login, timeoutMs) {
2591
2822
  return new Promise((resolve, reject) => {
2592
2823
  const timer = setTimeout(() => {
@@ -2652,6 +2883,7 @@ async function commandPair(runtime, options, io) {
2652
2883
  }
2653
2884
  }
2654
2885
  async function commandLogin(runtime, options, io) {
2886
+ if (options.cookies) return importFacebookCookies(runtime, options.cookies, io);
2655
2887
  const login = new FacebookLoginService({
2656
2888
  browserType: runtime.browserType,
2657
2889
  clearTimer: (timer) => clearTimeout(timer),
@@ -2670,49 +2902,128 @@ async function commandLogin(runtime, options, io) {
2670
2902
  return 0;
2671
2903
  }
2672
2904
  let display = null;
2905
+ let relay = null;
2673
2906
  try {
2674
2907
  const needsVirtualDisplay = process.platform === "linux" && !process.env.DISPLAY;
2675
2908
  if (!needsVirtualDisplay) {
2676
2909
  io.log(process.env.DISPLAY ? `Opening the Facebook login page on display ${process.env.DISPLAY}.` : "Opening the Facebook login page in a browser window.");
2677
2910
  } else {
2678
- const configuredHost = options.publicHost ?? process.env.DIGGYLAB_PUBLIC_HOST?.trim();
2911
+ const access = runtime.manager.getAccessCredential();
2912
+ if (!access) {
2913
+ io.error("This server is not paired yet. Run `diggylab pair --code <code>` first.");
2914
+ return 1;
2915
+ }
2916
+ const webUrl = options.webUrl ?? process.env.DIGGYLAB_WEB_URL?.trim();
2679
2917
  display = await startVirtualDisplay({
2680
- bind: options.bind,
2681
2918
  displayNumber: options.display,
2682
- novncWeb: options.novncWeb,
2683
- port: options.port,
2684
- publicHost: configuredHost || void 0,
2685
- tokenDirectory: join4(runtime.directory, "novnc")
2919
+ logDirectory: join4(runtime.directory, "logs")
2920
+ });
2921
+ relay = await openLoginScreenRelay({
2922
+ accessToken: access.accessToken,
2923
+ apiOrigin: runtime.endpoint.httpOrigin,
2924
+ key: createRelayKey(),
2925
+ rfbPort: display.rfbPort,
2926
+ webUrl: webUrl || void 0
2686
2927
  });
2687
- process.env.DISPLAY = `:${options.display ?? 99}`;
2688
- io.log(terminalQr(display.url));
2928
+ process.env.DISPLAY = display.display;
2929
+ io.log(terminalQr(relay.url));
2689
2930
  io.log(`Open this link on your phone, or scan the QR above:
2690
- ${display.url}`);
2691
- if (display.alternateUrl) {
2692
- io.log(`Phone on another network? Forward port ${options.port ?? 6080} and use:
2693
- ${display.alternateUrl}`);
2694
- }
2931
+ ${relay.url}`);
2695
2932
  io.log("Log into Facebook on that screen; the agent saves the session automatically.");
2933
+ void relay.screenConnected.then(() => io.log("Login screen opened in the browser."));
2696
2934
  }
2697
- await waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
2935
+ const waiting = waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
2936
+ const failures = [display?.failure, relay?.failure].filter((value) => Boolean(value));
2937
+ await (failures.length > 0 ? Promise.race([waiting, ...failures]) : waiting);
2698
2938
  io.log("Facebook session saved.");
2699
2939
  return 0;
2700
2940
  } catch (error) {
2701
2941
  const message = error instanceof Error ? error.message : String(error);
2702
- if (/spawn (?:Xvfb|x11vnc|websockify) ENOENT/.test(message)) {
2942
+ if (/spawn (?:Xvfb|x11vnc) ENOENT/.test(message)) {
2703
2943
  io.error(MISSING_DISPLAY_HINT);
2704
2944
  return 1;
2705
2945
  }
2946
+ if (/virtual screen|login screen|DiggyLab relay|Xvfb exited|x11vnc exited/i.test(message)) {
2947
+ io.error(`The login screen failed: ${message}`);
2948
+ printDisplayLogs(join4(runtime.directory, "logs"), io);
2949
+ return 1;
2950
+ }
2706
2951
  if (/Executable doesn't exist|playwright install/i.test(message)) {
2707
2952
  io.error(MISSING_BROWSER_HINT);
2708
2953
  return 1;
2709
2954
  }
2710
2955
  throw error;
2711
2956
  } finally {
2957
+ relay?.close();
2712
2958
  await login.close();
2713
2959
  await display?.stop();
2714
2960
  }
2715
2961
  }
2962
+ async function importFacebookCookies(runtime, source, io) {
2963
+ let input;
2964
+ try {
2965
+ input = source === "-" ? await readStdin() : readFileSync2(source, "utf8");
2966
+ } catch {
2967
+ io.error(`Could not read "${source}". Pass a file path, or "-" to paste the cookies instead.`);
2968
+ return 1;
2969
+ }
2970
+ const cookies = parseCookieInput(input);
2971
+ if (cookies.length === 0) {
2972
+ io.error("No cookies found there. Export the facebook.com cookies as JSON, or copy the browser's `cookie:` request header.");
2973
+ io.error("Cookie-Editor (Chrome, Edge, Firefox) \u2192 Export \u2192 JSON is the easiest way.");
2974
+ return 1;
2975
+ }
2976
+ const state = toFacebookStorageState(cookies);
2977
+ if (!state.cookies.some((cookie) => cookie.name === "c_user")) {
2978
+ io.error("Those cookies have no `c_user` value, so they are not a Facebook login.");
2979
+ io.error("Open facebook.com in your browser, make sure you are logged in, then export the facebook.com cookies.");
2980
+ return 1;
2981
+ }
2982
+ try {
2983
+ const accountId = await runtime.facebook.save(state);
2984
+ io.log(`Facebook session saved for account ${accountId}. Asking Facebook to confirm it...`);
2985
+ const condition = await validateImportedSession(runtime, state);
2986
+ if (condition !== "READY") {
2987
+ await runtime.facebook.clear();
2988
+ io.error(condition === "CHECKPOINT_REQUIRED" ? "Facebook asked for a security check on this server. Open facebook.com on your phone and approve the login, then import again." : "Facebook did not accept those cookies \u2014 they are usually expired. Log in again in your browser and export fresh ones.");
2989
+ return 1;
2990
+ }
2991
+ } catch (error) {
2992
+ const message = error instanceof Error ? error.message : String(error);
2993
+ if (/Executable doesn't exist|playwright install/i.test(message)) {
2994
+ io.error(MISSING_BROWSER_HINT);
2995
+ } else if (message === "FACEBOOK_SESSION_INVALID") {
2996
+ io.error("Those cookies are incomplete: the export must include `c_user` and `xs`.");
2997
+ } else {
2998
+ io.error(`Could not use those cookies: ${message}`);
2999
+ }
3000
+ return 1;
3001
+ }
3002
+ io.log("Facebook session saved.");
3003
+ return 0;
3004
+ }
3005
+ async function validateImportedSession(runtime, state) {
3006
+ const browser = await runtime.browserType.launch({ headless: true });
3007
+ try {
3008
+ const context = await browser.newContext({ storageState: state });
3009
+ const page = await context.newPage();
3010
+ await page.goto("https://www.facebook.com/", { timeout: 45e3, waitUntil: "domcontentloaded" });
3011
+ return await detectFacebookSession(page, context);
3012
+ } finally {
3013
+ await browser.close();
3014
+ }
3015
+ }
3016
+ function readStdin() {
3017
+ return new Promise((resolve, reject) => {
3018
+ let value = "";
3019
+ process.stdin.setEncoding("utf8");
3020
+ process.stdin.on("data", (chunk) => {
3021
+ value += chunk;
3022
+ });
3023
+ process.stdin.on("end", () => resolve(value));
3024
+ process.stdin.on("error", reject);
3025
+ });
3026
+ }
2716
3027
  function playwrightCliPath() {
2717
3028
  try {
2718
3029
  return fileURLToPath(new URL("cli.js", import.meta.resolve("playwright")));
@@ -2721,10 +3032,11 @@ function playwrightCliPath() {
2721
3032
  }
2722
3033
  }
2723
3034
  async function commandInstallBrowser(io) {
3035
+ const soft = process.env.npm_lifecycle_event === "postinstall";
2724
3036
  const cli = playwrightCliPath();
2725
3037
  if (!cli) {
2726
3038
  io.error("Could not locate the bundled Playwright. Reinstall with `npm install -g diggylab@latest`.");
2727
- return 1;
3039
+ return soft ? 0 : 1;
2728
3040
  }
2729
3041
  io.log("Downloading Chromium for Playwright...");
2730
3042
  const run = (args) => new Promise((resolve, reject) => {
@@ -2732,16 +3044,42 @@ async function commandInstallBrowser(io) {
2732
3044
  child.once("error", reject);
2733
3045
  child.once("exit", (value) => resolve(value ?? 1));
2734
3046
  });
2735
- let code = process.platform === "linux" ? await run(["install", "--with-deps", "chromium"]) : 0;
3047
+ let code = 1;
3048
+ for (const [index, args] of browserInstallCommands(process.platform).entries()) {
3049
+ if (index > 0) io.log("Installing the system libraries failed; downloading the browser only...");
3050
+ code = await run(args);
3051
+ if (code === 0) break;
3052
+ }
2736
3053
  if (code !== 0) {
2737
- io.log("Installing the system libraries failed; downloading the browser only...");
2738
- code = await run(["install", "chromium"]);
3054
+ io.error(soft ? "Chromium download failed. Run `diggylab install-browser` later." : "Chromium download failed. Check the network and try again.");
3055
+ return soft ? 0 : code;
3056
+ }
3057
+ io.log("Chromium installed.");
3058
+ return 0;
3059
+ }
3060
+ async function commandInstallDisplay(io) {
3061
+ if (process.platform !== "linux") {
3062
+ io.log("The shared login screen is only needed on a Linux server; desktops open a browser window.");
3063
+ return 0;
2739
3064
  }
3065
+ if (!existsSync2("/usr/bin/apt-get")) {
3066
+ io.error("Automatic installation supports Debian/Ubuntu. Install xvfb and x11vnc with your package manager.");
3067
+ return 1;
3068
+ }
3069
+ const sudo = typeof process.getuid === "function" && process.getuid() !== 0 ? ["sudo"] : [];
3070
+ const run = (args) => new Promise((resolve, reject) => {
3071
+ const child = spawn2(args[0], args.slice(1), { stdio: "inherit" });
3072
+ child.once("error", reject);
3073
+ child.once("exit", (value) => resolve(value ?? 1));
3074
+ });
3075
+ io.log("Installing xvfb and x11vnc...");
3076
+ await run([...sudo, "apt-get", "update"]).catch(() => 1);
3077
+ const code = await run([...sudo, "apt-get", "install", "-y", "xvfb", "x11vnc"]);
2740
3078
  if (code !== 0) {
2741
- io.error("Chromium download failed. Check the network and try again.");
3079
+ io.error("Installation failed. Run the same command with apt manually.");
2742
3080
  return code;
2743
3081
  }
2744
- io.log("Chromium installed.");
3082
+ io.log("Login screen packages installed.");
2745
3083
  return 0;
2746
3084
  }
2747
3085
  async function commandRun(runtime, io) {
@@ -2758,7 +3096,7 @@ async function commandRun(runtime, io) {
2758
3096
  agentVersion: agentVersion(),
2759
3097
  clearTimer: (timer) => clearTimeout(timer),
2760
3098
  createSocket: (url, accessToken) => {
2761
- const socket = new WebSocket(url, {
3099
+ const socket = new WebSocket2(url, {
2762
3100
  headers: { authorization: `Bearer ${accessToken}` },
2763
3101
  maxPayload: 16 * 1024
2764
3102
  });
@@ -2866,12 +3204,13 @@ async function main(argv, io = console) {
2866
3204
  io.log(USAGE);
2867
3205
  return 0;
2868
3206
  }
2869
- if (!["install-browser", "login", "pair", "run", "status", "unpair"].includes(command)) {
3207
+ if (!["install-browser", "install-display", "login", "pair", "run", "status", "unpair"].includes(command)) {
2870
3208
  io.error(`Unknown command "${command}".`);
2871
3209
  io.error(USAGE);
2872
3210
  return 2;
2873
3211
  }
2874
3212
  if (command === "install-browser") return commandInstallBrowser(io);
3213
+ if (command === "install-display") return commandInstallDisplay(io);
2875
3214
  const runtime = createRuntime(parsed.options);
2876
3215
  switch (command) {
2877
3216
  case "pair":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diggylab",
3
- "version": "0.1.35",
3
+ "version": "0.1.39",
4
4
  "description": "DiggyLab Desktop Agent collects posts and information from Facebook Groups on a paired computer. (headless CLI)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,9 @@
18
18
  "qrcode-terminal": "^0.12.0",
19
19
  "ws": "^8.21.3"
20
20
  },
21
+ "scripts": {
22
+ "postinstall": "node cli.js install-browser"
23
+ },
21
24
  "license": "UNLICENSED",
22
25
  "publishConfig": {
23
26
  "access": "public"