cetrix-connect 0.3.0 → 0.3.1

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 +20 -9
  2. package/dist/agent.mjs +180 -59
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -9,14 +9,15 @@ khi kết nối được cấp quyền.
9
9
  Cài **Node.js 24 trở lên**, mở PowerShell hoặc terminal trên máy cần ghép, rồi chạy:
10
10
 
11
11
  ```sh
12
- npx --yes cetrix-connect@0.3.0 remote
12
+ npx --yes cetrix-connect@0.3.1 remote
13
13
  ```
14
14
 
15
- Lệnh tải agent từ npm; không cần tải ZIP, giải nén hay tìm thư mục cài đặt. Làm
16
- theo lời nhắc để chọn tên máy thư mục dữ liệu đang tồn tại. Trình duyệt mở
17
- trang CetriX Connect để đăng nhập, đối chiếu ghép xác nhận.
15
+ Lệnh tự tải agent, lấy tên PC tạo thư mục **CetriX Workspace** trong thư mục
16
+ người dùng (ví dụ `C:\Users\Admin\CetriX Workspace`). Không phải nhập tên hay
17
+ đường dẫn trong terminal. Lần đầu, trình duyệt mở CetriX Connect để đăng nhập,
18
+ đối chiếu mã ghép và xác nhận tên máy, thư mục, quyền truy cập.
18
19
 
19
- Chỉ chọn thư mục muốn cấp quyền. Mặc định chỉ được đọc file. Quyền ghi file và
20
+ Mặc định chỉ được đọc file trong thư mục này. Quyền ghi file và
20
21
  chạy lệnh cần được bật rõ ràng. Khi bật chạy lệnh, tiến trình sử dụng quyền của
21
22
  tài khoản hệ điều hành hiện tại và không bị cách ly trong thư mục dữ liệu.
22
23
 
@@ -25,11 +26,10 @@ cài dịch vụ chạy nền hoặc tự khởi động cùng máy tính.
25
26
 
26
27
  ## Chạy lại máy đã ghép
27
28
 
28
- Sau khi ghép, agent in đường dẫn danh tính lệnh chạy lại. Sao chép chính lệnh
29
- đó để kết nối lại cùng máy, ví dụ:
29
+ Chạy lại chính lệnh ban đầu để dùng danh tính đã lưu, không cần ghép lại:
30
30
 
31
31
  ```sh
32
- npx --yes cetrix-connect@0.3.0 run --config "duong-dan-danh-tinh.json"
32
+ npx --yes cetrix-connect@0.3.1 remote
33
33
  ```
34
34
 
35
35
  Danh tính được lưu ngoài thư mục tải tạm của npm. Trên Windows, khóa được bảo
@@ -37,10 +37,21 @@ vệ bằng DPAPI của người dùng hiện tại; trên macOS/Linux, file ch
37
37
  sở hữu truy cập. Không gửi file danh tính cho người khác và không chuyển nó
38
38
  sang tài khoản Windows khác. Xóa cache npm không xóa danh tính đã lưu.
39
39
 
40
+ Để dùng thư mục có sẵn khác, mở **Tùy chọn kết nối** trên dashboard rồi sao chép
41
+ lệnh mới, hoặc thêm `--root "D:\DuAn"`. Có thể đổi tên bằng `--name "PC 01"`.
42
+ Thay đổi cấu hình kết nối cần xác nhận lại trên trình duyệt; không tự mở rộng
43
+ quyền của máy đã ghép. Khi dùng cấu hình tùy chỉnh, chạy lại cùng lệnh đó.
44
+
45
+ Danh tính cũ từ bộ ZIP hoặc agent 0.3.0 vẫn dùng được bằng lệnh chỉ định rõ:
46
+
47
+ ```sh
48
+ npx --yes cetrix-connect@0.3.1 run --config "duong-dan-danh-tinh.json"
49
+ ```
50
+
40
51
  Nếu dùng máy chủ CetriX khác, xem các tùy chọn:
41
52
 
42
53
  ```sh
43
- npx --yes cetrix-connect@0.3.0 --help
54
+ npx --yes cetrix-connect@0.3.1 --help
44
55
  ```
45
56
 
46
57
  Máy dùng kết nối HTTPS/WebSocket đi ra, không cần mở cổng đi vào. Agent Linux
package/dist/agent.mjs CHANGED
@@ -134,14 +134,13 @@ var init_files = __esm({
134
134
  });
135
135
 
136
136
  // src/agent/entry.mjs
137
- import fs7 from "node:fs/promises";
138
- import path9 from "node:path";
139
- import os2 from "node:os";
140
- import { generateKeyPairSync, randomUUID as randomUUID5 } from "node:crypto";
137
+ import fs8 from "node:fs/promises";
138
+ import path10 from "node:path";
139
+ import os3 from "node:os";
140
+ import { generateKeyPairSync, randomUUID as randomUUID6 } from "node:crypto";
141
141
  import { parseArgs } from "node:util";
142
142
  import { spawn as spawn4 } from "node:child_process";
143
143
  import { setTimeout as sleep } from "node:timers/promises";
144
- import { createInterface } from "node:readline/promises";
145
144
 
146
145
  // src/agent/identity.mjs
147
146
  import fs from "node:fs/promises";
@@ -2069,7 +2068,7 @@ var TOOL_DEFS = [
2069
2068
  var TOOL_NAMES = TOOL_DEFS.map((t) => t.name);
2070
2069
 
2071
2070
  // src/agent/version.mjs
2072
- var AGENT_VERSION = "0.3.0";
2071
+ var AGENT_VERSION = "0.3.1";
2073
2072
 
2074
2073
  // src/agent/runtime.mjs
2075
2074
  var states3 = /* @__PURE__ */ new WeakMap();
@@ -2331,8 +2330,9 @@ async function cleanupRuntime(config, filter = {}) {
2331
2330
  function connectAgent(config, { onReady = () => {
2332
2331
  }, onStatus = () => {
2333
2332
  }, onShutdown = () => {
2333
+ }, onInitialAuthorizationRejected = () => {
2334
2334
  }, reconnect = true, execute = executeRuntimeTool } = {}) {
2335
- let socket, timer, closed = false, retry = 0, generation = 0;
2335
+ let socket, timer, closed = false, retry = 0, generation = 0, everReady = false, authorizationReported = false;
2336
2336
  const revoked = /* @__PURE__ */ new Set();
2337
2337
  const stop = () => {
2338
2338
  closed = true;
@@ -2346,7 +2346,7 @@ function connectAgent(config, { onReady = () => {
2346
2346
  const url = new URL("/agent", config.server);
2347
2347
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
2348
2348
  socket = new WebSocket2(url, { maxPayload: 1024 * 1024 });
2349
- let ready = false, active2 = 0;
2349
+ let ready = false, active2 = 0, authSent = false, locallyRejected = false;
2350
2350
  const seen = /* @__PURE__ */ new Set();
2351
2351
  const authTimer = setTimeout(() => {
2352
2352
  if (!ready) socket.close();
@@ -2359,10 +2359,12 @@ function connectAgent(config, { onReady = () => {
2359
2359
  const signature = sign(null, Buffer.from(proofMessage(config.deviceId, msg.nonce)), config.privateKey).toString("base64url");
2360
2360
  const tools = TOOL_DEFS.filter((d) => (d.scope !== "files:write" || config.allowWrite) && (d.scope !== "terminal:execute" || config.allowShell)).map((d) => d.name);
2361
2361
  socket.send(JSON.stringify({ type: "auth", deviceId: config.deviceId, signature, version: AGENT_VERSION, tools }));
2362
+ authSent = true;
2362
2363
  return;
2363
2364
  }
2364
2365
  if (msg.type === "ready" && msg.deviceId === config.deviceId) {
2365
2366
  ready = true;
2367
+ everReady = true;
2366
2368
  retry = 0;
2367
2369
  clearTimeout(authTimer);
2368
2370
  onStatus("online");
@@ -2399,16 +2401,23 @@ function connectAgent(config, { onReady = () => {
2399
2401
  }
2400
2402
  }
2401
2403
  } catch {
2404
+ locallyRejected = true;
2402
2405
  socket.close(1008, "Invalid relay message");
2403
2406
  }
2404
2407
  });
2405
2408
  socket.on("error", () => onStatus("connection-error"));
2406
- socket.on("close", (code) => {
2409
+ socket.on("close", (code, reasonBytes) => {
2407
2410
  generation++;
2408
2411
  void cleanupRuntime(config);
2412
+ const reason = reasonBytes.toString(), replaced = code === 1e3 && reason === "Replaced by a new authenticated connection";
2409
2413
  clearTimeout(authTimer);
2410
- onStatus(code === 1008 ? "authorization-rejected" : "offline");
2411
- if (!closed && reconnect && code !== 1008) {
2414
+ onStatus(replaced ? "replaced-by-another-connection" : code === 1008 ? "authorization-rejected" : "offline");
2415
+ const initialRejection = !closed && !everReady && !locallyRejected && !authorizationReported && authSent && code === 1008 && ["Device authentication or message rejected", "Device authorization revoked"].includes(reason);
2416
+ if (initialRejection) {
2417
+ authorizationReported = true;
2418
+ onInitialAuthorizationRejected();
2419
+ }
2420
+ if (!closed && reconnect && code !== 1008 && !replaced) {
2412
2421
  timer = setTimeout(connect, Math.min(3e4, 1e3 * 2 ** Math.min(retry++, 5)) + Math.random() * 500);
2413
2422
  }
2414
2423
  });
@@ -2419,9 +2428,77 @@ function connectAgent(config, { onReady = () => {
2419
2428
  } };
2420
2429
  }
2421
2430
 
2431
+ // src/agent/local-session.mjs
2432
+ import fs7 from "node:fs/promises";
2433
+ import path9 from "node:path";
2434
+ import os2 from "node:os";
2435
+ import { createHash as createHash2, randomUUID as randomUUID5 } from "node:crypto";
2436
+ function agentStateDirectory() {
2437
+ return process.platform === "win32" ? path9.join(process.env.LOCALAPPDATA || os2.homedir(), "CetriX", "Connect", "agents") : path9.join(os2.homedir(), ".local", "share", "cetrix-connect", "agents");
2438
+ }
2439
+ async function defaultWorkspace(home = os2.homedir()) {
2440
+ const parent = await fs7.realpath(home), root = path9.join(parent, "CetriX Workspace");
2441
+ try {
2442
+ await fs7.mkdir(root, { mode: 448 });
2443
+ } catch (error) {
2444
+ if (error.code !== "EEXIST") throw error;
2445
+ }
2446
+ const stat = await fs7.lstat(root);
2447
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await fs7.realpath(root) !== root) throw new Error("CetriX Workspace must be a real directory, not a symlink or junction. Choose another folder with --root.");
2448
+ return root;
2449
+ }
2450
+ function remoteProfile(values) {
2451
+ return { server: values.server, root: values.root, name: values.name, allowWrite: !!values["allow-write"], allowShell: !!values["allow-shell"] };
2452
+ }
2453
+ function profileMatches(actual, expected) {
2454
+ return actual && Object.keys(expected).every((key) => actual[key] === expected[key]);
2455
+ }
2456
+ function sessionFile(stateDir, profile) {
2457
+ const hash = createHash2("sha256").update(JSON.stringify(remoteProfile({ server: profile.server, root: profile.root, name: profile.name, "allow-write": profile.allowWrite, "allow-shell": profile.allowShell }))).digest("hex");
2458
+ return path9.join(stateDir, "sessions", hash + ".json");
2459
+ }
2460
+ async function loadRemoteSession(stateDir, profile, loadIdentity2) {
2461
+ const pointer = sessionFile(stateDir, profile);
2462
+ let record;
2463
+ try {
2464
+ if ((await fs7.lstat(pointer)).isSymbolicLink()) throw new Error("Session pointer cannot be a symlink");
2465
+ record = JSON.parse(await fs7.readFile(pointer, "utf8"));
2466
+ } catch (error) {
2467
+ if (error.code === "ENOENT") return void 0;
2468
+ throw new Error("Unable to read saved connection: " + error.message);
2469
+ }
2470
+ if (record?.version !== 1 || !profileMatches(record.profile, profile) || typeof record.identity !== "string" || !/^[0-9a-f-]{36}\.json$/i.test(record.identity)) throw new Error("Saved connection does not match the requested profile");
2471
+ const filename = path9.join(stateDir, record.identity);
2472
+ try {
2473
+ if ((await fs7.lstat(filename)).isSymbolicLink()) throw new Error("Saved identity cannot be a symlink");
2474
+ const config = await loadIdentity2(filename);
2475
+ if (!profileMatches(config, profile) || typeof config.deviceId !== "string" || !config.deviceId || typeof config.privateKey !== "string" || !config.privateKey || typeof config.publicKey !== "string" || !config.publicKey) throw new Error("Saved identity does not match the requested profile");
2476
+ return { filename, config };
2477
+ } catch (error) {
2478
+ if (error.code === "ENOENT") throw new Error("The saved connection identity is missing. Repeat the same remote command with --logout to forget this profile, then run it again without --logout.");
2479
+ throw error;
2480
+ }
2481
+ }
2482
+ async function saveRemoteSession(stateDir, profile, filename) {
2483
+ if (path9.dirname(filename) !== path9.resolve(stateDir) || !/^[0-9a-f-]{36}\.json$/i.test(path9.basename(filename))) throw new Error("Cannot persist an identity outside the agent state directory");
2484
+ const pointer = sessionFile(stateDir, profile), temporary = pointer + "." + randomUUID5() + ".tmp";
2485
+ await fs7.mkdir(path9.dirname(pointer), { recursive: true, mode: 448 });
2486
+ try {
2487
+ await fs7.writeFile(temporary, JSON.stringify({ version: 1, profile, identity: path9.basename(filename) }), { mode: 384, flag: "wx" });
2488
+ await fs7.rename(temporary, pointer);
2489
+ } finally {
2490
+ await fs7.unlink(temporary).catch(() => {
2491
+ });
2492
+ }
2493
+ }
2494
+ async function forgetRemoteSession(stateDir, profile) {
2495
+ await fs7.unlink(sessionFile(stateDir, profile)).catch((error) => {
2496
+ if (error.code !== "ENOENT") throw error;
2497
+ });
2498
+ }
2499
+
2422
2500
  // src/agent/entry.mjs
2423
2501
  var shellQuote = (value) => "'" + value.replaceAll("'", process.platform === "win32" ? "''" : "'\\''") + "'";
2424
- var version = "0.3.0";
2425
2502
  var remoteServer = "https://connect.cetrix.app";
2426
2503
  var controlCharacters = /[\u0000-\u001f\u007f]/;
2427
2504
  function serverOrigin(value) {
@@ -2448,8 +2525,8 @@ function decodeOptions(value) {
2448
2525
  }
2449
2526
  return decoded;
2450
2527
  }
2451
- async function prepareAgentOptions(args, { npmMode = false, input = process.stdin, output = process.stdout, isTTY = !!(input.isTTY && output.isTTY), question } = {}) {
2452
- const { values: v, positionals: p } = parseArgs({ args, allowPositionals: true, options: { server: { type: "string" }, root: { type: "string" }, name: { type: "string" }, config: { type: "string" }, options: { type: "string" }, "allow-write": { type: "boolean" }, "allow-shell": { type: "boolean" }, "no-browser": { type: "boolean" }, help: { type: "boolean" } } });
2528
+ async function prepareAgentOptions(args, { npmMode = false, home = os3.homedir() } = {}) {
2529
+ const { values: v, positionals: p } = parseArgs({ args, allowPositionals: true, options: { server: { type: "string" }, root: { type: "string" }, name: { type: "string" }, config: { type: "string" }, options: { type: "string" }, "allow-write": { type: "boolean" }, "allow-shell": { type: "boolean" }, "no-browser": { type: "boolean" }, logout: { type: "boolean" }, help: { type: "boolean" } } });
2453
2530
  if (v.help || !p.length && !npmMode) return { help: true };
2454
2531
  if (p.length > 1) throw new Error("Choose one command: remote, setup, or run. Use --help.");
2455
2532
  const command = p[0] || (npmMode ? "remote" : void 0);
@@ -2463,35 +2540,32 @@ async function prepareAgentOptions(args, { npmMode = false, input = process.stdi
2463
2540
  v[target] = decoded[source];
2464
2541
  }
2465
2542
  }
2466
- if (command !== "remote") return { command, values: v };
2467
- v.server = serverOrigin(v.server || remoteServer);
2468
- if (!v.root && !isTTY) throw new Error('A data folder is required. Run remote in an interactive terminal, or add --root "C:\\Work" (an existing folder). No folder is granted automatically.');
2469
- let prompts2;
2470
- const ask = question || ((prompt) => {
2471
- prompts2 ||= createInterface({ input, output });
2472
- return prompts2.question(prompt);
2473
- });
2474
- try {
2475
- if (v.name === void 0 && isTTY) v.name = (await ask("Ten may [" + os2.hostname() + "]: ")).trim() || os2.hostname();
2476
- if (!v.root) v.root = (await ask("Thu muc du lieu (duong dan day du): ")).trim();
2477
- } finally {
2478
- prompts2?.close();
2543
+ if (command !== "remote") {
2544
+ if (v.logout) throw new Error("--logout is only supported by remote");
2545
+ return { command, values: v };
2546
+ }
2547
+ if (v.config !== void 0) {
2548
+ if (v.logout || ["root", "name", "allow-write", "allow-shell"].some((key) => v[key] !== void 0)) throw new Error("remote --config uses exactly the saved identity; do not combine it with root, name, permission, or logout options");
2549
+ if (v.server !== void 0) v.server = serverOrigin(v.server);
2550
+ return { command: "remote", values: v };
2479
2551
  }
2480
- v.name = (v.name ?? os2.hostname()).trim();
2552
+ v.server = serverOrigin(v.server || remoteServer);
2553
+ v.name = (v.name ?? os3.hostname()).trim();
2481
2554
  if (!v.name || v.name.length > 100 || controlCharacters.test(v.name)) throw new Error("Machine name must contain 1\u2013100 characters without control characters");
2555
+ if (v.root === void 0) v.root = await defaultWorkspace(home);
2482
2556
  v.root = v.root.trim();
2483
2557
  if (v.root.startsWith('"') && v.root.endsWith('"')) v.root = v.root.slice(1, -1);
2484
- if (v.root.length > 1024 || controlCharacters.test(v.root) || !path9.isAbsolute(v.root)) throw new Error("Select an existing data folder using its full absolute path");
2558
+ if (v.root.length > 1024 || controlCharacters.test(v.root) || !path10.isAbsolute(v.root)) throw new Error("Select an existing data folder using its full absolute path");
2485
2559
  let root;
2486
2560
  try {
2487
- root = await fs7.realpath(v.root);
2488
- if (!(await fs7.stat(root)).isDirectory()) throw new Error("Not a directory");
2561
+ root = await fs8.realpath(v.root);
2562
+ if (!(await fs8.stat(root)).isDirectory()) throw new Error("Not a directory");
2489
2563
  } catch {
2490
2564
  throw new Error("Data folder does not exist or cannot be opened: " + v.root);
2491
2565
  }
2492
- if (root === path9.parse(root).root) throw new Error("Select a specific work folder, not a drive root");
2566
+ if (root === path10.parse(root).root) throw new Error("Select a specific work folder, not a drive root");
2493
2567
  v.root = root;
2494
- return { command: "setup", values: v };
2568
+ return { command: "remote", values: v };
2495
2569
  }
2496
2570
  function openBrowser(url) {
2497
2571
  const child = process.platform === "win32" ? spawn4("rundll32.exe", ["url.dll,FileProtocolHandler", url], { windowsHide: true, stdio: "ignore" }) : spawn4(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore" });
@@ -2507,24 +2581,31 @@ async function post(server, route, body) {
2507
2581
  async function runAgent(args = process.argv.slice(2), options = {}) {
2508
2582
  const { help, command, values: v } = await prepareAgentOptions(args, options);
2509
2583
  if (help) {
2510
- console.log("CetriX Connect agent " + version + "\n\nQuick connect: npx --yes cetrix-connect@" + version + ' remote\nRemote options: --name "PC 01" --root "C:\\Work" --server https://your-host\nSetup: node dist/agent.mjs setup --server https://your-host --root "C:\\Work" --name "PC 01"\nOptional explicit permissions: --allow-write --allow-shell\nRun: ' + (options.npmMode ? "npx --yes cetrix-connect@" + version : "node dist/agent.mjs") + ' run --config "path-to-device.json"\n\nDefault: read-only. Keep the agent open; Ctrl+C disconnects.');
2584
+ console.log("CetriX Connect agent " + AGENT_VERSION + "\n\nQuick connect: npx --yes cetrix-connect@" + AGENT_VERSION + ' remote\nAutomatically uses this PC name and creates ~/CetriX Workspace. Approve once in your browser; repeat the same command to reconnect.\nRemote options: --name "PC 01" --root "C:\\Work" --server https://your-host\nLocal logout: remote --logout (forgets this profile; does not revoke it on the server)\nSetup: node dist/agent.mjs setup --server https://your-host --root "C:\\Work" --name "PC 01"\nOptional explicit permissions: --allow-write --allow-shell\nRun: ' + (options.npmMode ? "npx --yes cetrix-connect@" + AGENT_VERSION : "node dist/agent.mjs") + ' run --config "path-to-device.json"\n\nDefault: read-only. Keep the agent open; Ctrl+C disconnects.');
2511
2585
  return;
2512
2586
  }
2513
- let config, filename;
2514
- if (command === "setup") {
2587
+ const stateDir = options.stateDir || agentStateDirectory();
2588
+ const profile = command === "remote" && !v.config ? remoteProfile(v) : void 0;
2589
+ if (v.logout) {
2590
+ await forgetRemoteSession(stateDir, profile);
2591
+ console.log("Local connection forgotten. Server device and grants are unchanged. Run the same remote command without --logout to pair again.");
2592
+ return;
2593
+ }
2594
+ let config, filename, wasResumed = false;
2595
+ const enroll = async () => {
2515
2596
  if (!v.server || !v.root) throw new Error("--server and --root are required. No home-directory access is granted by default.");
2516
- const server = serverOrigin(v.server), root = await fs7.realpath(v.root);
2517
- if (!(await fs7.stat(root)).isDirectory() || root === path9.parse(root).root) throw new Error("Select a specific work folder, not a drive root");
2518
- const stateDir = process.platform === "win32" ? path9.join(process.env.LOCALAPPDATA || os2.homedir(), "CetriX", "Connect", "agents") : path9.join(os2.homedir(), ".local", "share", "cetrix-connect", "agents");
2519
- filename = path9.resolve(v.config || path9.join(stateDir, randomUUID5() + ".json"));
2597
+ const server = serverOrigin(v.server), root = await fs8.realpath(v.root);
2598
+ if (!(await fs8.stat(root)).isDirectory() || root === path10.parse(root).root) throw new Error("Select a specific work folder, not a drive root");
2599
+ if (profile && root !== profile.root) throw new Error("Data folder changed while preparing the connection; run remote again.");
2600
+ filename = path10.resolve(v.config || path10.join(stateDir, randomUUID6() + ".json"));
2520
2601
  try {
2521
- await fs7.access(filename);
2602
+ await fs8.access(filename);
2522
2603
  throw new Error("Config already exists; use run or choose a new config path");
2523
2604
  } catch (error) {
2524
2605
  if (error.code !== "ENOENT") throw error;
2525
2606
  }
2526
2607
  const { publicKey, privateKey } = generateKeyPairSync("ed25519", { publicKeyEncoding: { type: "spki", format: "pem" }, privateKeyEncoding: { type: "pkcs8", format: "pem" } });
2527
- const name = v.name || os2.hostname(), allowWrite = !!v["allow-write"], allowShell = !!v["allow-shell"];
2608
+ const name = v.name || os3.hostname(), allowWrite = !!v["allow-write"], allowShell = !!v["allow-shell"];
2528
2609
  console.log("CetriX Connect - " + name + "\nApproved root: " + root + "\nWrite: " + allowWrite + " | Shell: " + allowShell);
2529
2610
  if (allowShell) console.log("WARNING: Shell runs with this OS account privileges. It is NOT sandboxed to the root folder.");
2530
2611
  const begun = await post(server, "/api/enrollment/start", { name, root, publicKey, allowWrite, allowShell });
@@ -2544,22 +2625,66 @@ async function runAgent(args = process.argv.slice(2), options = {}) {
2544
2625
  if (!["authorization_pending", "slow_down"].includes(polled.data.error)) throw new Error(polled.data.error_description || "Enrollment failed");
2545
2626
  }
2546
2627
  if (!paired) throw new Error("Pairing expired. Run setup again.");
2547
- config = { server, root, name, allowWrite, allowShell, deviceId: paired.device_id, privateKey, publicKey, deniedPaths: [stateDir, path9.dirname(filename), path9.join(projectDir, "data"), path9.resolve("data")] };
2628
+ if (typeof paired.device_id !== "string" || !paired.device_id || paired.device_id.length > 128 || controlCharacters.test(paired.device_id)) throw new Error("Server returned an invalid paired device");
2629
+ config = { server, root, name, allowWrite, allowShell, deviceId: paired.device_id, privateKey, publicKey, deniedPaths: [stateDir, path10.dirname(filename), path10.join(projectDir, "data"), path10.resolve("data")] };
2548
2630
  await saveIdentity(filename, config);
2549
- const restart = options.npmMode ? "npx --yes cetrix-connect@" + version : "node " + shellQuote(path9.resolve(process.argv[1]));
2550
- console.log("\nMCP endpoint: " + paired.endpoint + "\nIdentity saved to: " + filename + "\nNext run: " + restart + " run --config " + shellQuote(filename));
2551
- } else if (command === "run") {
2631
+ if (profile) await saveRemoteSession(stateDir, profile, filename);
2632
+ const restart = options.npmMode ? "npx --yes cetrix-connect@" + AGENT_VERSION : "node " + shellQuote(path10.resolve(process.argv[1]));
2633
+ console.log("\nMCP endpoint: " + paired.endpoint + "\nIdentity saved to: " + filename);
2634
+ if (profile) console.log("Next run: repeat the same remote command. This connection is saved; no pairing is needed again.");
2635
+ else console.log("Next run: " + restart + " run --config " + shellQuote(filename));
2636
+ };
2637
+ if (command === "remote" && v.config) {
2638
+ filename = path10.resolve(v.config);
2639
+ config = await loadIdentity(filename);
2640
+ config.server = serverOrigin(config.server);
2641
+ if (v.server !== void 0 && v.server !== config.server) throw new Error("Saved identity belongs to another server");
2642
+ console.log("Restoring selected device: " + config.deviceId);
2643
+ } else if (profile) {
2644
+ const saved = await loadRemoteSession(stateDir, profile, loadIdentity);
2645
+ if (saved) {
2646
+ ({ filename, config } = saved);
2647
+ wasResumed = true;
2648
+ console.log("Restoring saved device: " + config.deviceId + "\nApproved root: " + config.root);
2649
+ } else await enroll();
2650
+ } else if (command === "setup") await enroll();
2651
+ else if (command === "run") {
2552
2652
  if (!v.config) throw new Error("--config is required");
2553
- filename = path9.resolve(v.config);
2653
+ filename = path10.resolve(v.config);
2554
2654
  config = await loadIdentity(filename);
2555
2655
  config.server = serverOrigin(config.server);
2556
- config.deniedPaths = [...config.deniedPaths || [], path9.dirname(filename), path9.join(projectDir, "data"), path9.resolve("data")];
2557
- } else throw new Error("Expected setup or run. Use --help.");
2656
+ } else throw new Error("Expected remote, setup, or run. Use --help.");
2657
+ let agent;
2658
+ const start2 = async (allowRepair) => {
2659
+ config.deniedPaths = [...config.deniedPaths || [], stateDir, path10.dirname(filename), path10.join(projectDir, "data"), path10.resolve("data")];
2660
+ await loadPreferences(config, filename);
2661
+ agent = connectAgent(config, {
2662
+ onStatus: (status) => console.log((/* @__PURE__ */ new Date()).toISOString() + " " + status),
2663
+ onShutdown: () => process.exit(0),
2664
+ onInitialAuthorizationRejected: allowRepair ? async () => {
2665
+ try {
2666
+ await agent.close();
2667
+ console.log("The saved device was rejected or revoked. Confirm a NEW pairing in your browser. Existing device permissions are not restored.");
2668
+ await enroll();
2669
+ await start2(false);
2670
+ } catch (error) {
2671
+ console.error("CetriX Connect:", error.message);
2672
+ process.exitCode = 1;
2673
+ }
2674
+ } : void 0
2675
+ });
2676
+ };
2677
+ await start2(wasResumed);
2678
+ for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => {
2679
+ Promise.resolve(agent.close()).finally(() => process.exit(0));
2680
+ });
2681
+ }
2682
+ async function loadPreferences(config, filename) {
2558
2683
  const settingsFile = filename + ".settings.json";
2559
2684
  try {
2560
- const settings = JSON.parse(await fs7.readFile(settingsFile, "utf8"));
2685
+ const settings = JSON.parse(await fs8.readFile(settingsFile, "utf8"));
2561
2686
  if (!settings || typeof settings !== "object" || Array.isArray(settings)) throw new Error("Invalid preferences object");
2562
- if (settings.allowedDirectories !== void 0 && (!Array.isArray(settings.allowedDirectories) || !settings.allowedDirectories.length || settings.allowedDirectories.length > 20 || settings.allowedDirectories.some((p) => typeof p !== "string" || !path9.isAbsolute(p) || p.includes("\0")))) throw new Error("Invalid allowed directories");
2687
+ if (settings.allowedDirectories !== void 0 && (!Array.isArray(settings.allowedDirectories) || !settings.allowedDirectories.length || settings.allowedDirectories.length > 20 || settings.allowedDirectories.some((p) => typeof p !== "string" || !path10.isAbsolute(p) || p.includes("\0")))) throw new Error("Invalid allowed directories");
2563
2688
  if (settings.blockedCommands !== void 0 && (!Array.isArray(settings.blockedCommands) || settings.blockedCommands.length > 100 || settings.blockedCommands.some((p) => typeof p !== "string" || !p.trim() || p.length > 200))) throw new Error("Invalid blocked commands");
2564
2689
  for (const key of ["fileReadLineLimit", "fileWriteLineLimit"]) if (settings[key] !== void 0 && (!Number.isInteger(settings[key]) || settings[key] < 1 || settings[key] > 2e3)) throw new Error("Invalid line limit");
2565
2690
  if (settings.defaultShell !== void 0 && !(process.platform === "win32" ? ["powershell.exe", "pwsh.exe", "cmd.exe"] : ["/bin/sh", "/bin/bash", "/bin/zsh"]).includes(settings.defaultShell)) throw new Error("Invalid default shell");
@@ -2569,19 +2694,15 @@ async function runAgent(args = process.argv.slice(2), options = {}) {
2569
2694
  if (error.code !== "ENOENT") throw new Error("Unable to load agent settings: " + error.message);
2570
2695
  }
2571
2696
  config.savePreferences = async (preferences) => {
2572
- const temporary = settingsFile + "." + randomUUID5() + ".tmp";
2697
+ const temporary = settingsFile + "." + randomUUID6() + ".tmp";
2573
2698
  try {
2574
- await fs7.writeFile(temporary, JSON.stringify(preferences, null, 2), { mode: 384, flag: "wx" });
2575
- await fs7.rename(temporary, settingsFile);
2699
+ await fs8.writeFile(temporary, JSON.stringify(preferences, null, 2), { mode: 384, flag: "wx" });
2700
+ await fs8.rename(temporary, settingsFile);
2576
2701
  } finally {
2577
- await fs7.unlink(temporary).catch(() => {
2702
+ await fs8.unlink(temporary).catch(() => {
2578
2703
  });
2579
2704
  }
2580
2705
  };
2581
- const agent = connectAgent(config, { onStatus: (status) => console.log((/* @__PURE__ */ new Date()).toISOString() + " " + status), onShutdown: () => process.exit(0) });
2582
- for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => {
2583
- Promise.resolve(agent.close()).finally(() => process.exit(0));
2584
- });
2585
2706
  }
2586
2707
 
2587
2708
  // src/agent/remote.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cetrix-connect",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Connect one approved computer to your CetriX Connect account",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",