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.
- package/README.md +20 -9
- package/dist/agent.mjs +180 -59
- 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.
|
|
12
|
+
npx --yes cetrix-connect@0.3.1 remote
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Lệnh tải agent
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
Lệnh tự tải agent, lấy tên PC và 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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
138
|
-
import
|
|
139
|
-
import
|
|
140
|
-
import { generateKeyPairSync, randomUUID as
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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")
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
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.
|
|
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) || !
|
|
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
|
|
2488
|
-
if (!(await
|
|
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 ===
|
|
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: "
|
|
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 " +
|
|
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
|
-
|
|
2514
|
-
|
|
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
|
|
2517
|
-
if (!(await
|
|
2518
|
-
|
|
2519
|
-
filename =
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
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 =
|
|
2653
|
+
filename = path10.resolve(v.config);
|
|
2554
2654
|
config = await loadIdentity(filename);
|
|
2555
2655
|
config.server = serverOrigin(config.server);
|
|
2556
|
-
|
|
2557
|
-
|
|
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
|
|
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" || !
|
|
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 + "." +
|
|
2697
|
+
const temporary = settingsFile + "." + randomUUID6() + ".tmp";
|
|
2573
2698
|
try {
|
|
2574
|
-
await
|
|
2575
|
-
await
|
|
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
|
|
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
|