@nordbyte/nordrelay 0.2.1 → 0.3.0
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/.env.example +22 -0
- package/CHANGELOG.md +17 -0
- package/README.md +130 -8
- package/dist/access-control.js +6 -0
- package/dist/agent-adapter.js +60 -0
- package/dist/audit-log.js +54 -0
- package/dist/bot-preferences.js +13 -9
- package/dist/bot-ui.js +6 -0
- package/dist/bot.js +521 -24
- package/dist/channel-adapter.js +58 -0
- package/dist/config.js +47 -0
- package/dist/index.js +47 -2
- package/dist/logger.js +24 -1
- package/dist/operations.js +339 -14
- package/dist/prompt-store.js +33 -11
- package/dist/relay-runtime.js +479 -0
- package/dist/session-locks.js +81 -0
- package/dist/session-registry.js +10 -6
- package/dist/settings-service.js +230 -0
- package/dist/state-backend.js +74 -0
- package/dist/web-dashboard.js +764 -0
- package/package.json +4 -1
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +199 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nordbyte/nordrelay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Remote control plane for coding agents across messaging channels.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Ricardo",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"dist/",
|
|
31
31
|
"plugins/",
|
|
32
32
|
"launchd/",
|
|
33
|
+
"CHANGELOG.md",
|
|
33
34
|
".env.example",
|
|
34
35
|
"Dockerfile",
|
|
35
36
|
"docker-compose.yml"
|
|
@@ -41,6 +42,8 @@
|
|
|
41
42
|
"foreground": "node plugins/nordrelay/scripts/nordrelay.mjs foreground",
|
|
42
43
|
"prepack": "npm run build",
|
|
43
44
|
"prepublishOnly": "npm run check && npm test && npm run build",
|
|
45
|
+
"security:audit": "npm audit --audit-level=high",
|
|
46
|
+
"sbom": "npm sbom --json > sbom.json",
|
|
44
47
|
"status": "node plugins/nordrelay/scripts/nordrelay.mjs status",
|
|
45
48
|
"start": "node plugins/nordrelay/scripts/nordrelay.mjs start",
|
|
46
49
|
"stop": "node plugins/nordrelay/scripts/nordrelay.mjs stop",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nordrelay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Run a remote-control bridge for coding agents. The current adapter connects Codex sessions to Telegram with streaming replies, multi-session controls, attachments, voice input, model selection, thread browsing, and handback.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ricardo",
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import fsp from "node:fs/promises";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import process from "node:process";
|
|
8
|
+
import readline from "node:readline/promises";
|
|
7
9
|
import { spawn } from "node:child_process";
|
|
8
10
|
import { fileURLToPath } from "node:url";
|
|
9
11
|
|
|
10
|
-
const VERSION = "0.
|
|
12
|
+
const VERSION = "0.3.0";
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
11
14
|
const APP_NAME = "nordrelay";
|
|
12
15
|
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
13
16
|
const PLUGIN_ROOT = path.resolve(path.dirname(SCRIPT_PATH), "..");
|
|
@@ -31,12 +34,23 @@ function parseArgs(argv) {
|
|
|
31
34
|
rawFlags: copy,
|
|
32
35
|
home: process.env.NORDRELAY_HOME || DEFAULT_HOME,
|
|
33
36
|
dropPendingUpdates: !envFlag("NORDRELAY_KEEP_PENDING_UPDATES"),
|
|
37
|
+
force: false,
|
|
38
|
+
host: process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1",
|
|
39
|
+
port: Number.parseInt(process.env.NORDRELAY_DASHBOARD_PORT || "31878", 10),
|
|
34
40
|
};
|
|
35
41
|
|
|
36
42
|
for (let i = 0; i < copy.length; i += 1) {
|
|
37
43
|
const arg = copy[i];
|
|
38
44
|
if (arg === "--home") options.home = requireValue(copy, ++i, arg);
|
|
39
45
|
else if (arg === "--keep-pending-updates") options.dropPendingUpdates = false;
|
|
46
|
+
else if (arg === "--force") options.force = true;
|
|
47
|
+
else if (arg === "--host") options.host = requireValue(copy, ++i, arg);
|
|
48
|
+
else if (arg === "--port") options.port = Number.parseInt(requireValue(copy, ++i, arg), 10);
|
|
49
|
+
else if (arg === "--token") options.telegramBotToken = requireValue(copy, ++i, arg);
|
|
50
|
+
else if (arg === "--admin-id") options.telegramAdminUserIds = requireValue(copy, ++i, arg);
|
|
51
|
+
else if (arg === "--state-backend") options.stateBackend = requireValue(copy, ++i, arg);
|
|
52
|
+
else if (arg === "--enable-pi") options.enablePi = true;
|
|
53
|
+
else if (arg === "--disable-codex") options.disableCodex = true;
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
options.pidFile = path.join(options.home, "nordrelay.pid");
|
|
@@ -254,6 +268,124 @@ async function commandStatus(options) {
|
|
|
254
268
|
if (state.error) console.log(`Error: ${state.error}`);
|
|
255
269
|
}
|
|
256
270
|
|
|
271
|
+
async function commandInit(options) {
|
|
272
|
+
await mkdirp(options.home);
|
|
273
|
+
const envPath = path.join(options.home, "nordrelay.env");
|
|
274
|
+
if (fs.existsSync(envPath) && !options.force) {
|
|
275
|
+
console.log(`Config already exists: ${envPath}`);
|
|
276
|
+
console.log("Run with --force to overwrite.");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const rl = process.stdin.isTTY
|
|
281
|
+
? readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
282
|
+
: null;
|
|
283
|
+
try {
|
|
284
|
+
const telegramBotToken = options.telegramBotToken ||
|
|
285
|
+
process.env.TELEGRAM_BOT_TOKEN ||
|
|
286
|
+
await ask(rl, "Telegram bot token", "");
|
|
287
|
+
const telegramAdminUserIds = options.telegramAdminUserIds ||
|
|
288
|
+
process.env.TELEGRAM_ADMIN_USER_IDS ||
|
|
289
|
+
await ask(rl, "Telegram admin user id", "");
|
|
290
|
+
const enableCodex = options.disableCodex ? "false" : await askChoice(rl, "Enable Codex", "true");
|
|
291
|
+
const enablePi = options.enablePi ? "true" : await askChoice(rl, "Enable Pi", "false");
|
|
292
|
+
const stateBackend = options.stateBackend || await askChoice(rl, "State backend (json/sqlite)", "json");
|
|
293
|
+
|
|
294
|
+
if (!telegramBotToken) throw new Error("Telegram bot token is required.");
|
|
295
|
+
if (!telegramAdminUserIds) throw new Error("Telegram admin user id is required.");
|
|
296
|
+
if (enableCodex !== "true" && enablePi !== "true") throw new Error("At least one agent must be enabled.");
|
|
297
|
+
|
|
298
|
+
const lines = [
|
|
299
|
+
"# NordRelay local runtime config.",
|
|
300
|
+
"# Keep this file private; it contains bot credentials.",
|
|
301
|
+
`TELEGRAM_BOT_TOKEN=${telegramBotToken}`,
|
|
302
|
+
`TELEGRAM_ADMIN_USER_IDS=${telegramAdminUserIds}`,
|
|
303
|
+
"TELEGRAM_ALLOW_ANY_CHAT=false",
|
|
304
|
+
`NORDRELAY_CODEX_ENABLED=${enableCodex}`,
|
|
305
|
+
`NORDRELAY_PI_ENABLED=${enablePi}`,
|
|
306
|
+
`NORDRELAY_DEFAULT_AGENT=${enableCodex === "true" ? "codex" : "pi"}`,
|
|
307
|
+
`NORDRELAY_STATE_BACKEND=${stateBackend === "sqlite" ? "sqlite" : "json"}`,
|
|
308
|
+
"TELEGRAM_TRANSPORT=polling",
|
|
309
|
+
"TELEGRAM_AUTO_SEND_ARTIFACTS=false",
|
|
310
|
+
"",
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
await fsp.writeFile(envPath, lines.join("\n"), { mode: 0o600 });
|
|
314
|
+
await fsp.chmod(envPath, 0o600).catch(() => {});
|
|
315
|
+
console.log(`Wrote ${envPath}`);
|
|
316
|
+
console.log("Run `nordrelay doctor` to validate the setup.");
|
|
317
|
+
} finally {
|
|
318
|
+
rl?.close();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function commandDoctor(options) {
|
|
323
|
+
await mkdirp(options.home);
|
|
324
|
+
loadEnvFiles(options.home);
|
|
325
|
+
const checks = [];
|
|
326
|
+
checks.push(check("Node.js >= 22", Number.parseInt(process.versions.node.split(".")[0], 10) >= 22, process.version));
|
|
327
|
+
checks.push(check("Telegram bot token", Boolean(process.env.TELEGRAM_BOT_TOKEN), process.env.TELEGRAM_BOT_TOKEN ? "configured" : "missing"));
|
|
328
|
+
checks.push(check("Telegram admin ids", Boolean(process.env.TELEGRAM_ADMIN_USER_IDS), process.env.TELEGRAM_ADMIN_USER_IDS ? "configured" : "missing"));
|
|
329
|
+
checks.push(check("Private by default", process.env.TELEGRAM_ALLOW_ANY_CHAT !== "true", "TELEGRAM_ALLOW_ANY_CHAT is not true"));
|
|
330
|
+
checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
|
|
331
|
+
checks.push(check("Pi enabled flag", process.env.NORDRELAY_PI_ENABLED === "true" || process.env.NORDRELAY_PI_ENABLED === undefined, `NORDRELAY_PI_ENABLED=${process.env.NORDRELAY_PI_ENABLED ?? "false"}`, process.env.NORDRELAY_PI_ENABLED === "true" ? "pass" : "warn"));
|
|
332
|
+
checks.push(check("Codex CLI", Boolean(findExecutable(process.env.CODEX_CLI_PATH || "codex")), process.env.CODEX_CLI_PATH || findExecutable("codex") || "not found", process.env.NORDRELAY_CODEX_ENABLED === "false" ? "warn" : "fail"));
|
|
333
|
+
checks.push(check("Pi CLI", Boolean(findExecutable(process.env.PI_CLI_PATH || "pi")), process.env.PI_CLI_PATH || findExecutable("pi") || "not found", process.env.NORDRELAY_PI_ENABLED === "true" ? "fail" : "warn"));
|
|
334
|
+
checks.push(check("ffmpeg", Boolean(findExecutable("ffmpeg")), findExecutable("ffmpeg") || "not found", "warn"));
|
|
335
|
+
checks.push(check("State backend", validateStateBackend(), `NORDRELAY_STATE_BACKEND=${process.env.NORDRELAY_STATE_BACKEND ?? "json"}`));
|
|
336
|
+
checks.push(check("Runtime entry", Boolean(await resolveRuntimeEntry()), RUNTIME_ROOT));
|
|
337
|
+
|
|
338
|
+
for (const item of checks) {
|
|
339
|
+
console.log(`${item.icon} ${item.name}: ${item.detail}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const failed = checks.filter((item) => item.status === "fail" && !item.ok);
|
|
343
|
+
const warned = checks.filter((item) => item.status === "warn" && !item.ok);
|
|
344
|
+
console.log(`\nSummary: ${failed.length} failed, ${warned.length} warnings.`);
|
|
345
|
+
if (failed.length > 0) process.exitCode = 1;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function commandWeb(options) {
|
|
349
|
+
await mkdirp(options.home);
|
|
350
|
+
loadEnvFiles(options.home);
|
|
351
|
+
const host = options.host || "127.0.0.1";
|
|
352
|
+
const port = Number.isFinite(options.port) ? options.port : 31878;
|
|
353
|
+
const entry = await resolveWebRuntimeEntry();
|
|
354
|
+
if (!entry) {
|
|
355
|
+
throw new Error(`Missing dashboard runtime. Run \`npm install\` and \`npm run build\` in ${RUNTIME_ROOT}.`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const env = {
|
|
359
|
+
...process.env,
|
|
360
|
+
NORDRELAY_HOME: options.home,
|
|
361
|
+
NORDRELAY_SOURCE_ROOT: RUNTIME_ROOT,
|
|
362
|
+
NORDRELAY_DASHBOARD_HOST: host,
|
|
363
|
+
NORDRELAY_DASHBOARD_PORT: String(port),
|
|
364
|
+
};
|
|
365
|
+
const child = spawn(entry.command, [...entry.args, "--host", host, "--port", String(port), "--home", options.home], {
|
|
366
|
+
cwd: RUNTIME_ROOT,
|
|
367
|
+
env,
|
|
368
|
+
stdio: "inherit",
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const forwardSignal = (signal) => {
|
|
372
|
+
if (isProcessRunning(child.pid)) {
|
|
373
|
+
child.kill(signal);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
process.once("SIGINT", () => forwardSignal("SIGINT"));
|
|
377
|
+
process.once("SIGTERM", () => forwardSignal("SIGTERM"));
|
|
378
|
+
|
|
379
|
+
const exit = await new Promise((resolve) => {
|
|
380
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
381
|
+
});
|
|
382
|
+
if (exit.signal) {
|
|
383
|
+
process.kill(process.pid, exit.signal);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
process.exit(exit.code ?? 0);
|
|
387
|
+
}
|
|
388
|
+
|
|
257
389
|
async function commandForeground(options) {
|
|
258
390
|
await mkdirp(options.home);
|
|
259
391
|
loadEnvFiles(options.home);
|
|
@@ -338,6 +470,21 @@ async function resolveRuntimeEntry() {
|
|
|
338
470
|
return null;
|
|
339
471
|
}
|
|
340
472
|
|
|
473
|
+
async function resolveWebRuntimeEntry() {
|
|
474
|
+
const distEntry = path.join(RUNTIME_ROOT, "dist", "web-dashboard.js");
|
|
475
|
+
if (fs.existsSync(distEntry)) {
|
|
476
|
+
return { command: process.execPath, args: [distEntry] };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const tsEntry = path.join(RUNTIME_ROOT, "src", "web-dashboard.ts");
|
|
480
|
+
const tsxBin = path.join(RUNTIME_ROOT, "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx");
|
|
481
|
+
if (fs.existsSync(tsEntry) && fs.existsSync(tsxBin)) {
|
|
482
|
+
return { command: tsxBin, args: [tsEntry] };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
341
488
|
function findRuntimeRoot() {
|
|
342
489
|
const candidates = [
|
|
343
490
|
process.env.NORDRELAY_SOURCE_ROOT,
|
|
@@ -366,6 +513,53 @@ function findRuntimeRoot() {
|
|
|
366
513
|
return DEFAULT_MARKETPLACE_ROOT;
|
|
367
514
|
}
|
|
368
515
|
|
|
516
|
+
async function ask(rl, label, defaultValue) {
|
|
517
|
+
if (!rl) return defaultValue;
|
|
518
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
519
|
+
const answer = (await rl.question(`${label}${suffix}: `)).trim();
|
|
520
|
+
return answer || defaultValue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function askChoice(rl, label, defaultValue) {
|
|
524
|
+
const value = (await ask(rl, label, defaultValue)).toLowerCase();
|
|
525
|
+
if (["1", "yes", "y", "true", "on"].includes(value)) return "true";
|
|
526
|
+
if (["0", "no", "n", "false", "off"].includes(value)) return "false";
|
|
527
|
+
return value || defaultValue;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function check(name, ok, detail, status = "fail") {
|
|
531
|
+
return {
|
|
532
|
+
name,
|
|
533
|
+
ok,
|
|
534
|
+
detail,
|
|
535
|
+
status,
|
|
536
|
+
icon: ok ? "✅" : status === "warn" ? "⚠️" : "❌",
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function findExecutable(command) {
|
|
541
|
+
if (!command) return null;
|
|
542
|
+
if (command.includes(path.sep) && fs.existsSync(command)) return command;
|
|
543
|
+
const paths = (process.env.PATH || "").split(path.delimiter);
|
|
544
|
+
for (const dir of paths) {
|
|
545
|
+
const candidate = path.join(dir, command);
|
|
546
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
547
|
+
}
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function validateStateBackend() {
|
|
552
|
+
const backend = process.env.NORDRELAY_STATE_BACKEND || "json";
|
|
553
|
+
if (backend === "json") return true;
|
|
554
|
+
if (backend !== "sqlite") return false;
|
|
555
|
+
try {
|
|
556
|
+
require("better-sqlite3");
|
|
557
|
+
return true;
|
|
558
|
+
} catch {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
369
563
|
function sleep(ms) {
|
|
370
564
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
371
565
|
}
|
|
@@ -375,6 +569,9 @@ async function main() {
|
|
|
375
569
|
if (options.command === "start") return commandStart(options);
|
|
376
570
|
if (options.command === "stop") return commandStop(options);
|
|
377
571
|
if (options.command === "status") return commandStatus(options);
|
|
572
|
+
if (options.command === "init") return commandInit(options);
|
|
573
|
+
if (options.command === "doctor") return commandDoctor(options);
|
|
574
|
+
if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
|
|
378
575
|
if (options.command === "restart") {
|
|
379
576
|
await commandStop(options);
|
|
380
577
|
return commandStart(options);
|
|
@@ -386,7 +583,7 @@ async function main() {
|
|
|
386
583
|
}
|
|
387
584
|
|
|
388
585
|
console.error(`Unknown command: ${options.command}`);
|
|
389
|
-
console.error("Usage: nordrelay [start|stop|restart|status|foreground]");
|
|
586
|
+
console.error("Usage: nordrelay [init|doctor|web|start|stop|restart|status|foreground|version]");
|
|
390
587
|
process.exitCode = 2;
|
|
391
588
|
}
|
|
392
589
|
|