diggylab 0.1.36 → 0.1.40

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 +66 -17
  2. package/cli.js +421 -94
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,7 +7,7 @@ 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` — run `diggylab install-display` (Debian/Ubuntu) or install them yourself. The Docker image below bundles them.
10
+ - Linux packages: `xvfb` and `x11vnc` — run `diggylab install-display` (Debian/Ubuntu) or install them yourself. The Docker image below bundles them.
11
11
  - Chromium for Playwright: downloaded automatically during `npm install`; rerun `diggylab install-browser` if that download failed.
12
12
 
13
13
  ## Install
@@ -58,37 +58,86 @@ 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
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`, `x11vnc`, `websockify`, and `novnc` for the shared login screen. |
65
+ | `install-display` | On Debian/Ubuntu, installs `xvfb` and `x11vnc` for the login screen. |
66
66
  | `unpair` | Removes device credentials, the Facebook session, and pending scans. |
67
67
 
68
- Useful options: `--api <origin>` (default `https://api.diggylab.com`), `--port` / `--bind` /
69
- `--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.
70
70
 
71
71
  ## How `login` works on a server
72
72
 
73
- A server has no screen, so the agent starts `Xvfb` (virtual screen), `x11vnc` (shares it) and
74
- `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:
75
75
 
76
76
  ```
77
- 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
78
78
  ```
79
79
 
80
- The noVNC URL carries a one-time token, the port is only open while `login` runs, and everything is
81
- torn down when the login finishes or times out (10 minutes). If you prefer not to expose a port,
82
- 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).
83
83
 
84
- ### Which address the link uses
84
+ ### The screen is encrypted end to end
85
85
 
86
- The link points at the machine's default-route address, so a multi-homed server advertises the
87
- network it actually reaches the internet through. When that address is private, the agent also looks
88
- 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`.
89
89
 
90
- Pin it explicitly with `--public-host <ip or domain>` or `DIGGYLAB_PUBLIC_HOST`, for example when a
91
- 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. Get the `facebook.com` cookies, in whichever way is easiest:
117
+ - **DevTools → Network** (fastest): open facebook.com, click any request, copy the whole
118
+ `cookie:` value from *Request Headers*.
119
+ - **DevTools → Application → Cookies → `https://www.facebook.com`**: select the table and copy the
120
+ rows.
121
+ - **Cookie-Editor** (Chrome, Edge, Firefox): *Export → JSON*.
122
+ 2. On the server:
123
+
124
+ ```bash
125
+ diggylab login --cookies cookies.json # or: diggylab login --cookies - and paste, then Ctrl-D
126
+ ```
127
+
128
+ Only `name` and `value` are required; the agent fills in the rest (`.facebook.com`, `/`, session
129
+ expiry). Every `facebook.com` cookie is kept, and at least `c_user` plus `xs` must be present — those
130
+ two carry the session, and `datr` is worth keeping too.
131
+
132
+ Do not use `document.cookie` from the Console: Facebook's `c_user` and `xs` are HttpOnly, so they
133
+ never appear there. The DevTools cookie table and the extension do show them.
134
+
135
+ The agent saves the cookies encrypted and opens Facebook once to confirm the session before it
136
+ accepts it. Cookies exported from a different account, or expired ones, are rejected with a message
137
+ instead of failing during a scan.
138
+
139
+ Note: Facebook sometimes asks for a security check when a session moves to a server's IP. If that
140
+ happens the command says so — approve the login from your phone and import again.
92
141
 
93
142
  ## Security notes
94
143
 
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 = {};
@@ -2357,6 +2355,91 @@ function browserInstallCommands(platform) {
2357
2355
  return platform === "linux" ? [["install", "--with-deps", "chromium"], install] : [install];
2358
2356
  }
2359
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(/[;\n]/).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 parseCookieTable(input) {
2396
+ const cookies = [];
2397
+ for (const line of input.split("\n")) {
2398
+ const columns = line.split(" ");
2399
+ if (columns.length < 2) return [];
2400
+ const name = columns[0].trim();
2401
+ const value = columns[1];
2402
+ if (!name || name === "Name" || !value) continue;
2403
+ cookies.push({ name, value });
2404
+ }
2405
+ return cookies;
2406
+ }
2407
+ function parseCookieInput(input) {
2408
+ const trimmed = input.trim();
2409
+ if (!trimmed) return [];
2410
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
2411
+ return trimmed.includes(" ") ? parseCookieTable(trimmed) : parseCookieHeader(trimmed);
2412
+ }
2413
+ try {
2414
+ const parsed = JSON.parse(trimmed);
2415
+ if (Array.isArray(parsed)) {
2416
+ return parsed.map((entry) => entry && typeof entry === "object" ? toCookie(entry) : null).filter((cookie) => cookie !== null);
2417
+ }
2418
+ if (parsed && typeof parsed === "object") {
2419
+ const cookies = parsed.cookies;
2420
+ if (Array.isArray(cookies)) {
2421
+ return cookies.map((entry) => entry && typeof entry === "object" ? toCookie(entry) : null).filter((cookie) => cookie !== null);
2422
+ }
2423
+ }
2424
+ } catch {
2425
+ return [];
2426
+ }
2427
+ return [];
2428
+ }
2429
+ function toFacebookStorageState(cookies) {
2430
+ const usable = cookies.filter((cookie) => isFacebookDomain(cookie.domain) || cookie.domain === void 0).map((cookie) => ({
2431
+ domain: isFacebookDomain(cookie.domain) ? cookie.domain : FACEBOOK_DOMAIN,
2432
+ expires: typeof cookie.expires === "number" && cookie.expires > 0 ? cookie.expires : -1,
2433
+ httpOnly: cookie.httpOnly ?? true,
2434
+ name: cookie.name,
2435
+ path: cookie.path?.startsWith("/") ? cookie.path : "/",
2436
+ sameSite: cookie.sameSite ?? "Lax",
2437
+ secure: cookie.secure ?? true,
2438
+ value: cookie.value
2439
+ }));
2440
+ return { cookies: usable, origins: [] };
2441
+ }
2442
+
2360
2443
  // src/cli/file-crypto.ts
2361
2444
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2362
2445
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -2404,6 +2487,163 @@ function createFileCrypto(keyPath) {
2404
2487
  };
2405
2488
  }
2406
2489
 
2490
+ // src/cli/login-screen-relay.ts
2491
+ import { createConnection } from "node:net";
2492
+ import WebSocket from "ws";
2493
+
2494
+ // src/cli/relay-crypto.ts
2495
+ import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, hkdfSync, randomBytes as randomBytes2 } from "node:crypto";
2496
+ var FRAME_VERSION = 1;
2497
+ var IV_BYTES2 = 12;
2498
+ var KEY_BYTES2 = 32;
2499
+ var TAG_BYTES2 = 16;
2500
+ function createRelayKey() {
2501
+ return randomBytes2(KEY_BYTES2).toString("base64url");
2502
+ }
2503
+ function deriveRelayKeys(key, sessionId) {
2504
+ const secret = Buffer.from(key, "base64url");
2505
+ const salt = Buffer.from(sessionId, "utf8");
2506
+ const derive = (info) => Buffer.from(hkdfSync("sha256", secret, salt, Buffer.from(info, "utf8"), KEY_BYTES2));
2507
+ return { fromAgent: derive("agent-to-browser"), toAgent: derive("browser-to-agent") };
2508
+ }
2509
+ function sealFrame(key, payload) {
2510
+ const iv = randomBytes2(IV_BYTES2);
2511
+ const cipher = createCipheriv2("aes-256-gcm", key, iv);
2512
+ const sealed = Buffer.concat([cipher.update(payload), cipher.final()]);
2513
+ return Buffer.concat([Buffer.from([FRAME_VERSION]), iv, sealed, cipher.getAuthTag()]);
2514
+ }
2515
+ function openFrame(key, frame) {
2516
+ if (frame.length < 1 + IV_BYTES2 + TAG_BYTES2 || frame[0] !== FRAME_VERSION) {
2517
+ throw new Error("Invalid login screen frame.");
2518
+ }
2519
+ const decipher = createDecipheriv2("aes-256-gcm", key, frame.subarray(1, 1 + IV_BYTES2));
2520
+ decipher.setAuthTag(frame.subarray(frame.length - TAG_BYTES2));
2521
+ return Buffer.concat([
2522
+ decipher.update(frame.subarray(1 + IV_BYTES2, frame.length - TAG_BYTES2)),
2523
+ decipher.final()
2524
+ ]);
2525
+ }
2526
+
2527
+ // src/cli/login-screen-relay.ts
2528
+ var RELAY_PATH = "/desktop-agent/login-screen";
2529
+ var READY_TIMEOUT_MS = 1e4;
2530
+ var DEFAULT_WEB_ORIGIN = "https://diggylab.com";
2531
+ async function openLoginScreenRelay(options) {
2532
+ const socket = new WebSocket(`${toWebSocketOrigin(options.apiOrigin)}${RELAY_PATH}`, {
2533
+ headers: { authorization: `Bearer ${options.accessToken}` }
2534
+ });
2535
+ let closed = false;
2536
+ let keys = null;
2537
+ let vnc = null;
2538
+ let resolveScreen = () => void 0;
2539
+ let rejectFailure = () => void 0;
2540
+ const failure = new Promise((_resolve, reject) => {
2541
+ rejectFailure = reject;
2542
+ });
2543
+ const screenConnected = new Promise((resolve) => {
2544
+ resolveScreen = resolve;
2545
+ });
2546
+ failure.catch(() => void 0);
2547
+ const close = () => {
2548
+ closed = true;
2549
+ vnc?.destroy();
2550
+ vnc = null;
2551
+ if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close();
2552
+ };
2553
+ const fail = (message) => {
2554
+ close();
2555
+ rejectFailure(new Error(message));
2556
+ };
2557
+ const openScreen = () => {
2558
+ if (closed || vnc) return;
2559
+ const connection = createConnection({ host: "127.0.0.1", port: options.rfbPort });
2560
+ vnc = connection;
2561
+ connection.on("data", (chunk) => {
2562
+ if (closed || !keys || socket.readyState !== WebSocket.OPEN) return;
2563
+ socket.send(sealFrame(keys.fromAgent, chunk), { binary: true });
2564
+ });
2565
+ connection.once("error", () => {
2566
+ if (vnc === connection) vnc = null;
2567
+ fail("Could not reach the virtual screen.");
2568
+ });
2569
+ connection.once("close", () => {
2570
+ if (vnc === connection) vnc = null;
2571
+ });
2572
+ };
2573
+ socket.on("message", (data, isBinary) => {
2574
+ if (isBinary) {
2575
+ if (!keys || !vnc || vnc.destroyed) return;
2576
+ try {
2577
+ vnc.write(openFrame(keys.toAgent, Buffer.from(data)));
2578
+ } catch {
2579
+ fail("The login screen received a corrupted frame.");
2580
+ }
2581
+ return;
2582
+ }
2583
+ const message = parseRelayMessage(data.toString());
2584
+ if (message?.type === "screen-connected") {
2585
+ openScreen();
2586
+ resolveScreen();
2587
+ } else if (message?.type === "screen-disconnected") {
2588
+ vnc?.destroy();
2589
+ vnc = null;
2590
+ }
2591
+ });
2592
+ socket.once("error", () => fail("Could not reach the DiggyLab relay."));
2593
+ socket.once("close", () => {
2594
+ if (!closed) fail("The DiggyLab relay closed the login screen.");
2595
+ });
2596
+ const ready = await waitForReady(socket);
2597
+ keys = deriveRelayKeys(options.key, ready.id);
2598
+ const webOrigin = (options.webUrl ?? ready.webUrl ?? DEFAULT_WEB_ORIGIN).replace(/\/+$/, "");
2599
+ return {
2600
+ close,
2601
+ failure,
2602
+ screenConnected,
2603
+ url: `${webOrigin}/agent-login/${encodeURIComponent(ready.id)}#${options.key}`
2604
+ };
2605
+ }
2606
+ function parseRelayMessage(value) {
2607
+ try {
2608
+ const parsed = JSON.parse(value);
2609
+ if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
2610
+ return parsed;
2611
+ }
2612
+ } catch {
2613
+ }
2614
+ return null;
2615
+ }
2616
+ function waitForReady(socket) {
2617
+ return new Promise((resolve, reject) => {
2618
+ const timer = setTimeout(() => reject(new Error("The DiggyLab relay did not answer in time.")), READY_TIMEOUT_MS);
2619
+ timer.unref();
2620
+ socket.once("message", (data, isBinary) => {
2621
+ clearTimeout(timer);
2622
+ if (isBinary) {
2623
+ reject(new Error("The DiggyLab relay answered with an unexpected frame."));
2624
+ return;
2625
+ }
2626
+ const message = parseRelayMessage(data.toString());
2627
+ if (message?.type !== "ready") {
2628
+ reject(new Error("The DiggyLab relay did not accept the login screen."));
2629
+ return;
2630
+ }
2631
+ resolve(message);
2632
+ });
2633
+ socket.once("error", () => {
2634
+ clearTimeout(timer);
2635
+ reject(new Error("Could not reach the DiggyLab relay."));
2636
+ });
2637
+ socket.once("close", () => {
2638
+ clearTimeout(timer);
2639
+ reject(new Error("The DiggyLab relay refused the login screen."));
2640
+ });
2641
+ });
2642
+ }
2643
+ function toWebSocketOrigin(origin) {
2644
+ return origin.replace(/^http/i, "ws").replace(/\/+$/, "");
2645
+ }
2646
+
2407
2647
  // src/cli/qr.ts
2408
2648
  import qrcode from "qrcode-terminal";
2409
2649
  function terminalQr(value) {
@@ -2416,19 +2656,41 @@ function terminalQr(value) {
2416
2656
 
2417
2657
  // src/cli/virtual-display.ts
2418
2658
  import { spawn } from "node:child_process";
2419
- import { randomBytes as randomBytes2 } from "node:crypto";
2420
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
2421
- import { createConnection } from "node:net";
2422
- import { networkInterfaces } from "node:os";
2659
+ import { createWriteStream, mkdirSync as mkdirSync2 } from "node:fs";
2660
+ import { createConnection as createConnection3 } from "node:net";
2423
2661
  import { join as join3 } from "node:path";
2662
+
2663
+ // src/cli/vnc-probe.ts
2664
+ import { createConnection as createConnection2 } from "node:net";
2665
+ var PROBE_TIMEOUT_MS = 4e3;
2666
+ var GREETING_BYTES = 12;
2667
+ var GREETING_PATTERN = /^RFB \d{3}\.\d{3}\n$/;
2668
+ function probeRfb(host, port, timeoutMs = PROBE_TIMEOUT_MS) {
2669
+ return new Promise((resolve) => {
2670
+ const socket = createConnection2({ host, port });
2671
+ const finish = (result) => {
2672
+ clearTimeout(timer);
2673
+ socket.destroy();
2674
+ resolve(result);
2675
+ };
2676
+ const timer = setTimeout(() => finish(false), timeoutMs);
2677
+ let received = "";
2678
+ socket.on("data", (chunk) => {
2679
+ received += chunk.toString("latin1");
2680
+ if (received.length >= GREETING_BYTES) finish(GREETING_PATTERN.test(received.slice(0, GREETING_BYTES)));
2681
+ });
2682
+ socket.once("error", () => finish(false));
2683
+ socket.once("close", () => finish(false));
2684
+ });
2685
+ }
2686
+
2687
+ // src/cli/virtual-display.ts
2424
2688
  var START_TIMEOUT_MS = 15e3;
2425
- var PUBLIC_IP_TIMEOUT_MS = 3e3;
2426
- var PUBLIC_IP_ENDPOINT = "https://api.ipify.org";
2427
2689
  function waitForPort(host, port, timeoutMs) {
2428
2690
  const deadline = Date.now() + timeoutMs;
2429
2691
  return new Promise((resolve, reject) => {
2430
2692
  const attempt = () => {
2431
- const socket = createConnection({ host, port });
2693
+ const socket = createConnection3({ host, port });
2432
2694
  socket.once("connect", () => {
2433
2695
  socket.destroy();
2434
2696
  resolve();
@@ -2445,94 +2707,56 @@ function waitForPort(host, port, timeoutMs) {
2445
2707
  attempt();
2446
2708
  });
2447
2709
  }
2448
- function isPrivateAddress(address) {
2449
- const [first, second] = address.split(".").map(Number);
2450
- 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;
2451
- }
2452
- function defaultRouteInterface() {
2453
- if (process.platform !== "linux") return null;
2454
- try {
2455
- for (const line of readFileSync2("/proc/net/route", "utf8").split("\n").slice(1)) {
2456
- const [iface, destination, , , , , flags] = line.trim().split(/\s+/);
2457
- if (destination === "00000000" && Number.parseInt(flags ?? "0", 16) & 2) return iface;
2458
- }
2459
- } catch {
2460
- }
2461
- return null;
2462
- }
2463
- function localAddress() {
2464
- const interfaces = networkInterfaces();
2465
- const addressOf = (addresses) => addresses?.find((entry) => entry.family === "IPv4" && !entry.internal)?.address ?? null;
2466
- const preferred = defaultRouteInterface();
2467
- return (preferred ? addressOf(interfaces[preferred]) : null) ?? Object.values(interfaces).map(addressOf).find((address) => address !== null) ?? "127.0.0.1";
2468
- }
2469
- async function detectPublicIp(fetcher = fetch) {
2470
- try {
2471
- const response = await fetcher(PUBLIC_IP_ENDPOINT, { signal: AbortSignal.timeout(PUBLIC_IP_TIMEOUT_MS) });
2472
- if (!response.ok) return null;
2473
- const value = (await response.text()).trim();
2474
- return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) ? value : null;
2475
- } catch {
2476
- return null;
2477
- }
2478
- }
2479
- function start(command, args, env, deps) {
2710
+ function start(name, command, args, env, deps, logDirectory) {
2480
2711
  const child = deps.spawnProcess(command, args, env);
2712
+ const log = createWriteStream(join3(logDirectory, `${name}.log`), { mode: 384 });
2481
2713
  const failure = new Promise((_resolve, reject) => {
2482
2714
  child.on("error", (error) => reject(error instanceof Error ? error : new Error(String(error))));
2483
2715
  child.on("exit", (code) => reject(new Error(`${command} exited with code ${String(code)}.`)));
2484
2716
  });
2717
+ log.on("error", () => void 0);
2718
+ log.write(`# ${command} ${args.join(" ")}
2719
+ `);
2720
+ child.stdout?.pipe?.(log, { end: false });
2721
+ child.stderr?.pipe?.(log, { end: false });
2485
2722
  return { child, failure };
2486
2723
  }
2487
2724
  async function startVirtualDisplay(options, overrides = {}) {
2488
2725
  const deps = {
2489
- detectPublicIp: () => detectPublicIp(),
2490
- localAddress,
2726
+ probeRfb,
2491
2727
  spawnProcess: spawn,
2492
2728
  waitForPort,
2493
2729
  ...overrides
2494
2730
  };
2495
- const bind = options.bind ?? "0.0.0.0";
2496
2731
  const displayNumber = options.displayNumber ?? 99;
2732
+ const display = `:${displayNumber}`;
2497
2733
  const height = options.height ?? 900;
2498
- const novncWeb = options.novncWeb ?? "/usr/share/novnc";
2499
- const port = options.port ?? 6080;
2500
2734
  const rfbPort = 5900 + displayNumber;
2501
- const token = randomBytes2(18).toString("base64url");
2502
- const tokenFile = join3(options.tokenDirectory, "websockify-token");
2503
- mkdirSync2(options.tokenDirectory, { mode: 448, recursive: true });
2504
- writeFileSync2(tokenFile, `${token}: 127.0.0.1:${rfbPort}
2505
- `, { mode: 384 });
2506
- const xvfb = start("Xvfb", [`:${displayNumber}`, "-screen", `0`, `${options.width ?? 1280}x${height}x24`, "-nolisten", "tcp"], process.env, deps);
2507
- const vnc = start("x11vnc", ["-display", `:${displayNumber}`, "-localhost", "-rfbport", String(rfbPort), "-forever", "-shared", "-nopw", "-quiet"], process.env, deps);
2508
- const proxy = start("websockify", ["--web", novncWeb, "--token-plugin", "TokenFile", "--token-source", tokenFile, `${bind}:${port}`], process.env, deps);
2735
+ mkdirSync2(options.logDirectory, { mode: 448, recursive: true });
2736
+ const xvfb = start("xvfb", "Xvfb", [display, "-screen", "0", `${options.width ?? 1280}x${height}x24`, "-nolisten", "tcp"], process.env, deps, options.logDirectory);
2737
+ const vnc = start("x11vnc", "x11vnc", ["-display", display, "-localhost", "-rfbport", String(rfbPort), "-forever", "-shared", "-nopw", "-quiet"], process.env, deps, options.logDirectory);
2738
+ const failure = Promise.race([vnc.failure, xvfb.failure]);
2739
+ failure.catch(() => void 0);
2509
2740
  const stop = async () => {
2510
- for (const { child } of [proxy, vnc, xvfb]) {
2741
+ for (const { child } of [vnc, xvfb]) {
2511
2742
  child.kill("SIGTERM");
2512
2743
  await new Promise((resolve) => setTimeout(resolve, 200));
2513
2744
  child.kill("SIGKILL");
2514
2745
  }
2515
- rmSync(tokenFile, { force: true });
2516
2746
  };
2517
2747
  try {
2518
2748
  await Promise.race([
2519
- deps.waitForPort(bind === "0.0.0.0" ? "127.0.0.1" : bind, port, options.timeoutMs ?? START_TIMEOUT_MS),
2520
- proxy.failure,
2521
- vnc.failure,
2522
- xvfb.failure
2749
+ deps.waitForPort("127.0.0.1", rfbPort, options.timeoutMs ?? START_TIMEOUT_MS),
2750
+ failure
2523
2751
  ]);
2752
+ if (!await deps.probeRfb("127.0.0.1", rfbPort)) {
2753
+ throw new Error(`The virtual screen did not answer on 127.0.0.1:${rfbPort}.`);
2754
+ }
2524
2755
  } catch (error) {
2525
2756
  await stop();
2526
2757
  throw error;
2527
2758
  }
2528
- const publicHost = options.publicHost ?? deps.localAddress();
2529
- const link = (host) => `http://${host}:${port}/vnc.html?autoconnect=1&token=${encodeURIComponent(token)}`;
2530
- const publicIp = options.publicHost || !isPrivateAddress(publicHost) ? null : await deps.detectPublicIp();
2531
- return {
2532
- alternateUrl: publicIp && publicIp !== publicHost ? link(publicIp) : null,
2533
- stop,
2534
- url: link(publicHost)
2535
- };
2759
+ return { display, failure, logDirectory: options.logDirectory, rfbPort, stop };
2536
2760
  }
2537
2761
 
2538
2762
  // src/cli/cli.ts
@@ -2542,7 +2766,8 @@ var USAGE = `DiggyLab Desktop Agent CLI
2542
2766
 
2543
2767
  Usage:
2544
2768
  diggylab pair --code <code> [--name <device name>] [--api <https://api.diggylab.com>]
2545
- diggylab login [--port 6080] [--bind 0.0.0.0] [--public-host <ip or host>]
2769
+ diggylab login [--display <number>] [--web-url <https://diggylab.com>]
2770
+ diggylab login --cookies <file or -> (import cookies exported from your own browser)
2546
2771
  diggylab run
2547
2772
  diggylab status
2548
2773
  diggylab install-browser
@@ -2553,14 +2778,14 @@ Global options:
2553
2778
  --config <directory> Agent state directory (default ~/.diggylab)
2554
2779
  --api <origin> API origin (default $DIGGYLAB_API_URL or https://api.diggylab.com)`;
2555
2780
  var MISSING_BROWSER_HINT = "Chromium for Playwright is not installed yet. Run `diggylab install-browser` once.";
2556
- var MISSING_DISPLAY_HINT = "The shared login screen needs Xvfb, x11vnc, websockify, and noVNC. Run `diggylab install-display` (Debian/Ubuntu) or use the Docker image.";
2781
+ var MISSING_DISPLAY_HINT = "The login screen needs Xvfb and x11vnc. Run `diggylab install-display` (Debian/Ubuntu) or use the Docker image.";
2557
2782
  function agentVersion() {
2558
2783
  let directory = fileURLToPath(new URL(".", import.meta.url));
2559
2784
  for (let depth = 0; depth < 4; depth += 1) {
2560
2785
  const manifest = join4(directory, "package.json");
2561
2786
  if (existsSync2(manifest)) {
2562
2787
  try {
2563
- const version = JSON.parse(readFileSync3(manifest, "utf8")).version;
2788
+ const version = JSON.parse(readFileSync2(manifest, "utf8")).version;
2564
2789
  if (typeof version === "string" && version) return version;
2565
2790
  } catch {
2566
2791
  }
@@ -2594,6 +2819,19 @@ function createRuntime(options) {
2594
2819
  const manager = new DeviceCredentialManager(sessions, http, createDeviceIdentity);
2595
2820
  return { activity, browserType: createBrowserLauncher(chromium), directory, endpoint, facebook, manager, sessions, spool };
2596
2821
  }
2822
+ function printDisplayLogs(directory, io, lines = 12) {
2823
+ io.error(`Login screen logs: ${directory}`);
2824
+ try {
2825
+ for (const name of readdirSync(directory).sort()) {
2826
+ if (!name.endsWith(".log")) continue;
2827
+ const tail = readFileSync2(join4(directory, name), "utf8").split("\n").filter((line) => line.trim() !== "").slice(-lines);
2828
+ if (tail.length === 0) continue;
2829
+ io.error(`--- ${name} ---`);
2830
+ for (const line of tail) io.error(line);
2831
+ }
2832
+ } catch {
2833
+ }
2834
+ }
2597
2835
  function waitForFacebookLogin(login, timeoutMs) {
2598
2836
  return new Promise((resolve, reject) => {
2599
2837
  const timer = setTimeout(() => {
@@ -2659,6 +2897,7 @@ async function commandPair(runtime, options, io) {
2659
2897
  }
2660
2898
  }
2661
2899
  async function commandLogin(runtime, options, io) {
2900
+ if (options.cookies) return importFacebookCookies(runtime, options.cookies, io);
2662
2901
  const login = new FacebookLoginService({
2663
2902
  browserType: runtime.browserType,
2664
2903
  clearTimer: (timer) => clearTimeout(timer),
@@ -2677,49 +2916,137 @@ async function commandLogin(runtime, options, io) {
2677
2916
  return 0;
2678
2917
  }
2679
2918
  let display = null;
2919
+ let relay = null;
2680
2920
  try {
2681
2921
  const needsVirtualDisplay = process.platform === "linux" && !process.env.DISPLAY;
2682
2922
  if (!needsVirtualDisplay) {
2683
2923
  io.log(process.env.DISPLAY ? `Opening the Facebook login page on display ${process.env.DISPLAY}.` : "Opening the Facebook login page in a browser window.");
2684
2924
  } else {
2685
- const configuredHost = options.publicHost ?? process.env.DIGGYLAB_PUBLIC_HOST?.trim();
2925
+ const stored = await runtime.manager.load();
2926
+ if (!stored) {
2927
+ io.error("This server is not paired yet. Run `diggylab pair --code <code>` first.");
2928
+ return 1;
2929
+ }
2930
+ const access = await runtime.manager.refresh();
2931
+ const webUrl = options.webUrl ?? process.env.DIGGYLAB_WEB_URL?.trim();
2686
2932
  display = await startVirtualDisplay({
2687
- bind: options.bind,
2688
2933
  displayNumber: options.display,
2689
- novncWeb: options.novncWeb,
2690
- port: options.port,
2691
- publicHost: configuredHost || void 0,
2692
- tokenDirectory: join4(runtime.directory, "novnc")
2934
+ logDirectory: join4(runtime.directory, "logs")
2935
+ });
2936
+ relay = await openLoginScreenRelay({
2937
+ accessToken: access.accessToken,
2938
+ apiOrigin: runtime.endpoint.httpOrigin,
2939
+ key: createRelayKey(),
2940
+ rfbPort: display.rfbPort,
2941
+ webUrl: webUrl || void 0
2693
2942
  });
2694
- process.env.DISPLAY = `:${options.display ?? 99}`;
2695
- io.log(terminalQr(display.url));
2943
+ process.env.DISPLAY = display.display;
2944
+ io.log(terminalQr(relay.url));
2696
2945
  io.log(`Open this link on your phone, or scan the QR above:
2697
- ${display.url}`);
2698
- if (display.alternateUrl) {
2699
- io.log(`Phone on another network? Forward port ${options.port ?? 6080} and use:
2700
- ${display.alternateUrl}`);
2701
- }
2946
+ ${relay.url}`);
2702
2947
  io.log("Log into Facebook on that screen; the agent saves the session automatically.");
2948
+ void relay.screenConnected.then(() => io.log("Login screen opened in the browser."));
2703
2949
  }
2704
- await waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
2950
+ const waiting = waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
2951
+ const failures = [display?.failure, relay?.failure].filter((value) => Boolean(value));
2952
+ await (failures.length > 0 ? Promise.race([waiting, ...failures]) : waiting);
2705
2953
  io.log("Facebook session saved.");
2706
2954
  return 0;
2707
2955
  } catch (error) {
2708
2956
  const message = error instanceof Error ? error.message : String(error);
2709
- if (/spawn (?:Xvfb|x11vnc|websockify) ENOENT/.test(message)) {
2957
+ if (/spawn (?:Xvfb|x11vnc) ENOENT/.test(message)) {
2710
2958
  io.error(MISSING_DISPLAY_HINT);
2711
2959
  return 1;
2712
2960
  }
2961
+ if (/CREDENTIAL_REVOKED|not paired/i.test(message)) {
2962
+ io.error("This server is not paired (or was unpaired). Run `diggylab pair --code <code>` again.");
2963
+ return 1;
2964
+ }
2965
+ if (/NETWORK_ERROR|SERVER_UNAVAILABLE|fetch failed/i.test(message)) {
2966
+ io.error(`Could not reach the API: ${message}`);
2967
+ return 1;
2968
+ }
2969
+ if (/virtual screen|login screen|DiggyLab relay|Xvfb exited|x11vnc exited/i.test(message)) {
2970
+ io.error(`The login screen failed: ${message}`);
2971
+ printDisplayLogs(join4(runtime.directory, "logs"), io);
2972
+ return 1;
2973
+ }
2713
2974
  if (/Executable doesn't exist|playwright install/i.test(message)) {
2714
2975
  io.error(MISSING_BROWSER_HINT);
2715
2976
  return 1;
2716
2977
  }
2717
2978
  throw error;
2718
2979
  } finally {
2980
+ relay?.close();
2719
2981
  await login.close();
2720
2982
  await display?.stop();
2721
2983
  }
2722
2984
  }
2985
+ async function importFacebookCookies(runtime, source, io) {
2986
+ let input;
2987
+ try {
2988
+ input = source === "-" ? await readStdin() : readFileSync2(source, "utf8");
2989
+ } catch {
2990
+ io.error(`Could not read "${source}". Pass a file path, or "-" to paste the cookies instead.`);
2991
+ return 1;
2992
+ }
2993
+ const cookies = parseCookieInput(input);
2994
+ if (cookies.length === 0) {
2995
+ io.error("No cookies found there. Export the facebook.com cookies as JSON, or copy the browser's `cookie:` request header.");
2996
+ io.error("Cookie-Editor (Chrome, Edge, Firefox) \u2192 Export \u2192 JSON is the easiest way.");
2997
+ return 1;
2998
+ }
2999
+ const state = toFacebookStorageState(cookies);
3000
+ if (!state.cookies.some((cookie) => cookie.name === "c_user")) {
3001
+ io.error("Those cookies have no `c_user` value, so they are not a Facebook login.");
3002
+ io.error("Open facebook.com in your browser, make sure you are logged in, then export the facebook.com cookies.");
3003
+ return 1;
3004
+ }
3005
+ try {
3006
+ const accountId = await runtime.facebook.save(state);
3007
+ io.log(`Facebook session saved for account ${accountId}. Asking Facebook to confirm it...`);
3008
+ const condition = await validateImportedSession(runtime, state);
3009
+ if (condition !== "READY") {
3010
+ await runtime.facebook.clear();
3011
+ 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.");
3012
+ return 1;
3013
+ }
3014
+ } catch (error) {
3015
+ const message = error instanceof Error ? error.message : String(error);
3016
+ if (/Executable doesn't exist|playwright install/i.test(message)) {
3017
+ io.error(MISSING_BROWSER_HINT);
3018
+ } else if (message === "FACEBOOK_SESSION_INVALID") {
3019
+ io.error("Those cookies are incomplete: the export must include `c_user` and `xs`.");
3020
+ } else {
3021
+ io.error(`Could not use those cookies: ${message}`);
3022
+ }
3023
+ return 1;
3024
+ }
3025
+ io.log("Facebook session saved.");
3026
+ return 0;
3027
+ }
3028
+ async function validateImportedSession(runtime, state) {
3029
+ const browser = await runtime.browserType.launch({ headless: true });
3030
+ try {
3031
+ const context = await browser.newContext({ storageState: state });
3032
+ const page = await context.newPage();
3033
+ await page.goto("https://www.facebook.com/", { timeout: 45e3, waitUntil: "domcontentloaded" });
3034
+ return await detectFacebookSession(page, context);
3035
+ } finally {
3036
+ await browser.close();
3037
+ }
3038
+ }
3039
+ function readStdin() {
3040
+ return new Promise((resolve, reject) => {
3041
+ let value = "";
3042
+ process.stdin.setEncoding("utf8");
3043
+ process.stdin.on("data", (chunk) => {
3044
+ value += chunk;
3045
+ });
3046
+ process.stdin.on("end", () => resolve(value));
3047
+ process.stdin.on("error", reject);
3048
+ });
3049
+ }
2723
3050
  function playwrightCliPath() {
2724
3051
  try {
2725
3052
  return fileURLToPath(new URL("cli.js", import.meta.resolve("playwright")));
@@ -2759,7 +3086,7 @@ async function commandInstallDisplay(io) {
2759
3086
  return 0;
2760
3087
  }
2761
3088
  if (!existsSync2("/usr/bin/apt-get")) {
2762
- io.error("Automatic installation supports Debian/Ubuntu. Install xvfb, x11vnc, websockify, and noVNC with your package manager.");
3089
+ io.error("Automatic installation supports Debian/Ubuntu. Install xvfb and x11vnc with your package manager.");
2763
3090
  return 1;
2764
3091
  }
2765
3092
  const sudo = typeof process.getuid === "function" && process.getuid() !== 0 ? ["sudo"] : [];
@@ -2768,9 +3095,9 @@ async function commandInstallDisplay(io) {
2768
3095
  child.once("error", reject);
2769
3096
  child.once("exit", (value) => resolve(value ?? 1));
2770
3097
  });
2771
- io.log("Installing xvfb, x11vnc, websockify, and noVNC...");
3098
+ io.log("Installing xvfb and x11vnc...");
2772
3099
  await run([...sudo, "apt-get", "update"]).catch(() => 1);
2773
- const code = await run([...sudo, "apt-get", "install", "-y", "xvfb", "x11vnc", "websockify", "novnc"]);
3100
+ const code = await run([...sudo, "apt-get", "install", "-y", "xvfb", "x11vnc"]);
2774
3101
  if (code !== 0) {
2775
3102
  io.error("Installation failed. Run the same command with apt manually.");
2776
3103
  return code;
@@ -2792,7 +3119,7 @@ async function commandRun(runtime, io) {
2792
3119
  agentVersion: agentVersion(),
2793
3120
  clearTimer: (timer) => clearTimeout(timer),
2794
3121
  createSocket: (url, accessToken) => {
2795
- const socket = new WebSocket(url, {
3122
+ const socket = new WebSocket2(url, {
2796
3123
  headers: { authorization: `Bearer ${accessToken}` },
2797
3124
  maxPayload: 16 * 1024
2798
3125
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diggylab",
3
- "version": "0.1.36",
3
+ "version": "0.1.40",
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": {