@nordbyte/nordrelay 0.2.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 (45) hide show
  1. package/.env.example +88 -0
  2. package/Dockerfile +19 -0
  3. package/LICENSE +21 -0
  4. package/README.md +749 -0
  5. package/dist/access-control.js +146 -0
  6. package/dist/agent-factory.js +22 -0
  7. package/dist/agent.js +57 -0
  8. package/dist/artifacts.js +515 -0
  9. package/dist/attachments.js +69 -0
  10. package/dist/bot-preferences.js +146 -0
  11. package/dist/bot-ui.js +161 -0
  12. package/dist/bot.js +4520 -0
  13. package/dist/codex-auth.js +150 -0
  14. package/dist/codex-cli.js +79 -0
  15. package/dist/codex-config.js +50 -0
  16. package/dist/codex-launch.js +109 -0
  17. package/dist/codex-session.js +591 -0
  18. package/dist/codex-state.js +573 -0
  19. package/dist/config.js +385 -0
  20. package/dist/context-key.js +23 -0
  21. package/dist/error-messages.js +73 -0
  22. package/dist/format.js +121 -0
  23. package/dist/index.js +140 -0
  24. package/dist/logger.js +27 -0
  25. package/dist/operations.js +133 -0
  26. package/dist/persistence.js +65 -0
  27. package/dist/pi-cli.js +19 -0
  28. package/dist/pi-rpc.js +158 -0
  29. package/dist/pi-session.js +573 -0
  30. package/dist/pi-state.js +226 -0
  31. package/dist/prompt-store.js +241 -0
  32. package/dist/redaction.js +47 -0
  33. package/dist/session-format.js +191 -0
  34. package/dist/session-registry.js +195 -0
  35. package/dist/telegram-rate-limit.js +136 -0
  36. package/dist/voice.js +373 -0
  37. package/dist/workspace-policy.js +41 -0
  38. package/docker-compose.yml +17 -0
  39. package/launchd/start.sh +8 -0
  40. package/package.json +69 -0
  41. package/plugins/nordrelay/.codex-plugin/plugin.json +48 -0
  42. package/plugins/nordrelay/assets/nordrelay.svg +5 -0
  43. package/plugins/nordrelay/commands/remote.md +33 -0
  44. package/plugins/nordrelay/scripts/nordrelay.mjs +396 -0
  45. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +26 -0
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import fsp from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+ import { spawn } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const VERSION = "0.2.1";
11
+ const APP_NAME = "nordrelay";
12
+ const SCRIPT_PATH = fileURLToPath(import.meta.url);
13
+ const PLUGIN_ROOT = path.resolve(path.dirname(SCRIPT_PATH), "..");
14
+ const DEFAULT_MARKETPLACE_ROOT = path.resolve(PLUGIN_ROOT, "../..");
15
+ const RUNTIME_ROOT = findRuntimeRoot();
16
+ const DEFAULT_HOME = path.join(os.homedir(), ".codex", "nordrelay");
17
+
18
+ function nowIso() {
19
+ return new Date().toISOString();
20
+ }
21
+
22
+ function parseArgs(argv) {
23
+ const copy = [...argv];
24
+ let command = "foreground";
25
+ if (copy[0] && !copy[0].startsWith("-")) {
26
+ command = copy.shift();
27
+ }
28
+
29
+ const options = {
30
+ command,
31
+ rawFlags: copy,
32
+ home: process.env.NORDRELAY_HOME || DEFAULT_HOME,
33
+ dropPendingUpdates: !envFlag("NORDRELAY_KEEP_PENDING_UPDATES"),
34
+ };
35
+
36
+ for (let i = 0; i < copy.length; i += 1) {
37
+ const arg = copy[i];
38
+ if (arg === "--home") options.home = requireValue(copy, ++i, arg);
39
+ else if (arg === "--keep-pending-updates") options.dropPendingUpdates = false;
40
+ }
41
+
42
+ options.pidFile = path.join(options.home, "nordrelay.pid");
43
+ options.stateFile = path.join(options.home, "state.json");
44
+ options.logFile = path.join(options.home, "nordrelay.log");
45
+ return options;
46
+ }
47
+
48
+ function requireValue(argv, index, flag) {
49
+ const value = argv[index];
50
+ if (!value || value.startsWith("-")) {
51
+ throw new Error(`${flag} requires a value`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ function envFlag(name) {
57
+ const value = process.env[name];
58
+ return value === "1" || value === "true" || value === "yes" || value === "on";
59
+ }
60
+
61
+ async function mkdirp(dir) {
62
+ await fsp.mkdir(dir, { recursive: true });
63
+ }
64
+
65
+ function loadEnvFiles(home) {
66
+ const files = [
67
+ path.join(process.cwd(), ".env"),
68
+ path.join(RUNTIME_ROOT, ".env"),
69
+ path.join(PLUGIN_ROOT, ".env"),
70
+ path.join(home, "nordrelay.env"),
71
+ ];
72
+
73
+ for (const envPath of files) {
74
+ loadEnvFile(envPath);
75
+ }
76
+
77
+ normalizeEnvAliases();
78
+ }
79
+
80
+ function loadEnvFile(envPath) {
81
+ if (!fs.existsSync(envPath)) return;
82
+ const text = fs.readFileSync(envPath, "utf8");
83
+ for (const rawLine of text.split(/\r?\n/)) {
84
+ const line = rawLine.trim();
85
+ if (!line || line.startsWith("#")) continue;
86
+ const normalized = line.startsWith("export ") ? line.slice(7).trim() : line;
87
+ const equals = normalized.indexOf("=");
88
+ if (equals < 1) continue;
89
+ const key = normalized.slice(0, equals).trim();
90
+ let value = normalized.slice(equals + 1).trim();
91
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
92
+ if (
93
+ (value.startsWith('"') && value.endsWith('"')) ||
94
+ (value.startsWith("'") && value.endsWith("'"))
95
+ ) {
96
+ value = value.slice(1, -1);
97
+ }
98
+ if (process.env[key] === undefined) {
99
+ process.env[key] = value.replace(/\\n/g, "\n");
100
+ }
101
+ }
102
+ }
103
+
104
+ function normalizeEnvAliases() {
105
+ if (!process.env.TELEGRAM_ALLOWED_USER_IDS && process.env.TELEGRAM_ALLOWED_CHAT_IDS) {
106
+ process.env.TELEGRAM_ALLOWED_USER_IDS = process.env.TELEGRAM_ALLOWED_CHAT_IDS;
107
+ }
108
+
109
+ if (!process.env.TELEGRAM_ALLOWED_CHAT_IDS && process.env.TELEGRAM_ALLOWED_USER_IDS) {
110
+ process.env.TELEGRAM_ALLOWED_CHAT_IDS = process.env.TELEGRAM_ALLOWED_USER_IDS;
111
+ }
112
+
113
+ if (!process.env.TOOL_VERBOSITY && envFlag("NORDRELAY_FORWARD_TOOL_OUTPUT")) {
114
+ process.env.TOOL_VERBOSITY = "all";
115
+ }
116
+ }
117
+
118
+ async function readJson(filePath, fallback = null) {
119
+ try {
120
+ return JSON.parse(await fsp.readFile(filePath, "utf8"));
121
+ } catch {
122
+ return fallback;
123
+ }
124
+ }
125
+
126
+ async function writeJsonAtomic(filePath, payload) {
127
+ await mkdirp(path.dirname(filePath));
128
+ const tmp = `${filePath}.${process.pid}.tmp`;
129
+ await fsp.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`);
130
+ await fsp.rename(tmp, filePath);
131
+ }
132
+
133
+ function isProcessRunning(pid) {
134
+ if (!pid || !Number.isInteger(pid) || pid <= 0) return false;
135
+ try {
136
+ process.kill(pid, 0);
137
+ return true;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ async function readPid(pidFile) {
144
+ try {
145
+ const value = Number.parseInt((await fsp.readFile(pidFile, "utf8")).trim(), 10);
146
+ return Number.isFinite(value) ? value : null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ async function commandStart(options) {
153
+ await mkdirp(options.home);
154
+ loadEnvFiles(options.home);
155
+
156
+ const currentPid = await readPid(options.pidFile);
157
+ if (isProcessRunning(currentPid)) {
158
+ console.log(`Already running with PID ${currentPid}`);
159
+ await commandStatus(options);
160
+ return;
161
+ }
162
+
163
+ await writeJsonAtomic(options.stateFile, {
164
+ status: "starting",
165
+ pid: null,
166
+ updatedAt: nowIso(),
167
+ logFile: options.logFile,
168
+ });
169
+
170
+ const logFd = fs.openSync(options.logFile, "a");
171
+ const child = spawn(process.execPath, [SCRIPT_PATH, "foreground", ...options.rawFlags], {
172
+ cwd: RUNTIME_ROOT,
173
+ detached: true,
174
+ env: process.env,
175
+ stdio: ["ignore", logFd, logFd],
176
+ });
177
+ child.unref();
178
+ fs.closeSync(logFd);
179
+
180
+ await fsp.writeFile(options.pidFile, `${child.pid}\n`);
181
+
182
+ const state = await waitForState(options.stateFile, child.pid, 8000);
183
+ if (state?.status === "ready") {
184
+ console.log(`Started ${APP_NAME} ${VERSION} with PID ${child.pid}`);
185
+ console.log(`Workspace: ${state.workspace || "-"}`);
186
+ console.log(`Mode: ${state.sessionMode || "per Telegram context"}`);
187
+ console.log(`Log: ${options.logFile}`);
188
+ return;
189
+ }
190
+
191
+ if (state?.status === "error") {
192
+ if (!isProcessRunning(child.pid)) {
193
+ await fsp.rm(options.pidFile, { force: true });
194
+ }
195
+ console.log(`Startup failed. Log: ${options.logFile}`);
196
+ console.log(state.error || "Unknown error");
197
+ process.exitCode = 1;
198
+ return;
199
+ }
200
+
201
+ console.log(`Started ${APP_NAME} ${VERSION} with PID ${child.pid}`);
202
+ console.log(`Startup is still in progress. Log: ${options.logFile}`);
203
+ }
204
+
205
+ async function waitForState(stateFile, pid, timeoutMs) {
206
+ const deadline = Date.now() + timeoutMs;
207
+ while (Date.now() < deadline) {
208
+ const state = await readJson(stateFile);
209
+ if (state?.pid === pid && ["ready", "error"].includes(state.status)) {
210
+ return state;
211
+ }
212
+ if (!isProcessRunning(pid)) {
213
+ return await readJson(stateFile);
214
+ }
215
+ await sleep(250);
216
+ }
217
+ return await readJson(stateFile);
218
+ }
219
+
220
+ async function commandStop(options) {
221
+ const pid = await readPid(options.pidFile);
222
+ if (!isProcessRunning(pid)) {
223
+ console.log("Connector is not running.");
224
+ await fsp.rm(options.pidFile, { force: true });
225
+ return;
226
+ }
227
+
228
+ process.kill(pid, "SIGTERM");
229
+ for (let i = 0; i < 40; i += 1) {
230
+ if (!isProcessRunning(pid)) break;
231
+ await sleep(250);
232
+ }
233
+
234
+ if (isProcessRunning(pid)) {
235
+ console.log(`PID ${pid} did not exit after SIGTERM.`);
236
+ process.exitCode = 1;
237
+ } else {
238
+ await fsp.rm(options.pidFile, { force: true });
239
+ console.log(`Stopped ${APP_NAME} PID ${pid}.`);
240
+ }
241
+ }
242
+
243
+ async function commandStatus(options) {
244
+ const pid = await readPid(options.pidFile);
245
+ const state = await readJson(options.stateFile, {});
246
+ const running = isProcessRunning(pid);
247
+ console.log(`Status: ${state.status || (running ? "running" : "stopped")}`);
248
+ console.log(`PID: ${pid || "-"} (${running ? "running" : "not running"})`);
249
+ console.log(`Workspace: ${state.workspace || "-"}`);
250
+ console.log(`Mode: ${state.sessionMode || "per Telegram context"}`);
251
+ console.log(`Auth: ${state.authenticated === undefined ? "-" : state.authenticated ? "yes" : "no"}`);
252
+ console.log(`Codex CLI: ${state.codexCli || "-"}`);
253
+ console.log(`Log: ${options.logFile}`);
254
+ if (state.error) console.log(`Error: ${state.error}`);
255
+ }
256
+
257
+ async function commandForeground(options) {
258
+ await mkdirp(options.home);
259
+ loadEnvFiles(options.home);
260
+ process.chdir(RUNTIME_ROOT);
261
+
262
+ await writeJsonAtomic(options.stateFile, {
263
+ status: "starting",
264
+ pid: process.pid,
265
+ updatedAt: nowIso(),
266
+ logFile: options.logFile,
267
+ });
268
+
269
+ const entry = await resolveRuntimeEntry();
270
+ if (!entry) {
271
+ const message = `Missing runtime. Run \`npm install\` and \`npm run build\` in ${RUNTIME_ROOT}.`;
272
+ await writeJsonAtomic(options.stateFile, {
273
+ status: "error",
274
+ pid: process.pid,
275
+ updatedAt: nowIso(),
276
+ error: message,
277
+ logFile: options.logFile,
278
+ });
279
+ throw new Error(message);
280
+ }
281
+
282
+ const env = {
283
+ ...process.env,
284
+ NORDRELAY_HOME: options.home,
285
+ NORDRELAY_SOURCE_ROOT: RUNTIME_ROOT,
286
+ NORDRELAY_STATE_FILE: options.stateFile,
287
+ NORDRELAY_WRAPPER_PID: String(process.pid),
288
+ NORDRELAY_DROP_PENDING_UPDATES: options.dropPendingUpdates ? "1" : "0",
289
+ };
290
+
291
+ const child = spawn(entry.command, entry.args, {
292
+ cwd: RUNTIME_ROOT,
293
+ env,
294
+ stdio: "inherit",
295
+ });
296
+
297
+ const forwardSignal = (signal) => {
298
+ if (isProcessRunning(child.pid)) {
299
+ child.kill(signal);
300
+ }
301
+ };
302
+
303
+ process.once("SIGINT", () => forwardSignal("SIGINT"));
304
+ process.once("SIGTERM", () => forwardSignal("SIGTERM"));
305
+
306
+ const exit = await new Promise((resolve) => {
307
+ child.once("exit", (code, signal) => resolve({ code, signal }));
308
+ });
309
+
310
+ await writeJsonAtomic(options.stateFile, {
311
+ status: exit.code === 0 ? "stopped" : "error",
312
+ pid: process.pid,
313
+ updatedAt: nowIso(),
314
+ exitCode: exit.code,
315
+ signal: exit.signal,
316
+ logFile: options.logFile,
317
+ });
318
+
319
+ if (exit.signal) {
320
+ process.kill(process.pid, exit.signal);
321
+ return;
322
+ }
323
+ process.exit(exit.code ?? 0);
324
+ }
325
+
326
+ async function resolveRuntimeEntry() {
327
+ const distEntry = path.join(RUNTIME_ROOT, "dist", "index.js");
328
+ if (fs.existsSync(distEntry)) {
329
+ return { command: process.execPath, args: [distEntry] };
330
+ }
331
+
332
+ const tsEntry = path.join(RUNTIME_ROOT, "src", "index.ts");
333
+ const tsxBin = path.join(RUNTIME_ROOT, "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx");
334
+ if (fs.existsSync(tsEntry) && fs.existsSync(tsxBin)) {
335
+ return { command: tsxBin, args: [tsEntry] };
336
+ }
337
+
338
+ return null;
339
+ }
340
+
341
+ function findRuntimeRoot() {
342
+ const candidates = [
343
+ process.env.NORDRELAY_SOURCE_ROOT,
344
+ DEFAULT_MARKETPLACE_ROOT,
345
+ process.cwd(),
346
+ ].filter(Boolean);
347
+
348
+ for (const candidate of candidates) {
349
+ const root = path.resolve(candidate);
350
+ const packageJson = path.join(root, "package.json");
351
+ const distEntry = path.join(root, "dist", "index.js");
352
+ const srcEntry = path.join(root, "src", "index.ts");
353
+ if (!fs.existsSync(packageJson)) continue;
354
+ if (!fs.existsSync(distEntry) && !fs.existsSync(srcEntry)) continue;
355
+
356
+ try {
357
+ const pkg = JSON.parse(fs.readFileSync(packageJson, "utf8"));
358
+ if (pkg?.name === APP_NAME || pkg?.name === "nordrelay" || pkg?.name === "@nordbyte/nordrelay") {
359
+ return root;
360
+ }
361
+ } catch {
362
+ // Try the next candidate.
363
+ }
364
+ }
365
+
366
+ return DEFAULT_MARKETPLACE_ROOT;
367
+ }
368
+
369
+ function sleep(ms) {
370
+ return new Promise((resolve) => setTimeout(resolve, ms));
371
+ }
372
+
373
+ async function main() {
374
+ const options = parseArgs(process.argv.slice(2));
375
+ if (options.command === "start") return commandStart(options);
376
+ if (options.command === "stop") return commandStop(options);
377
+ if (options.command === "status") return commandStatus(options);
378
+ if (options.command === "restart") {
379
+ await commandStop(options);
380
+ return commandStart(options);
381
+ }
382
+ if (options.command === "foreground") return commandForeground(options);
383
+ if (options.command === "--version" || options.command === "version") {
384
+ console.log(`${APP_NAME} ${VERSION}`);
385
+ return;
386
+ }
387
+
388
+ console.error(`Unknown command: ${options.command}`);
389
+ console.error("Usage: nordrelay [start|stop|restart|status|foreground]");
390
+ process.exitCode = 2;
391
+ }
392
+
393
+ main().catch((error) => {
394
+ console.error(error instanceof Error ? error.message : String(error));
395
+ process.exitCode = 1;
396
+ });
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: telegram-remote
3
+ description: Use when the user wants to start, inspect, stop, or troubleshoot the NordRelay bot process, including prompts like "remote", "/remote", "Telegram Remote", "NordRelay", or Telegram remote control.
4
+ ---
5
+
6
+ # Telegram Remote
7
+
8
+ After the bot process is running, Telegram provides the actual controls (`/new`, `/sessions`, `/sync`, `/pinned`, `/pin`, `/unpin`, `/attach`, `/handback`, `/model`, `/reasoning`, `/fast`, `/launch_profiles`, `/retry`, `/queue`, `/cancel`, `/clearqueue`, `/artifacts`, `/abort`, `/stop`, `/tasks`, `/progress`, `/status`, `/health`, `/version`, `/logs`, `/diagnostics`, `/restart`, `/update`, voice, photos, documents, media groups, artifacts, login). The Codex-side command is only a process manager.
9
+
10
+ Use the local connector script in the plugin root. In a source checkout, the plugin root is usually:
11
+
12
+ ```text
13
+ <repo>/plugins/nordrelay
14
+ ```
15
+
16
+ Run commands from that directory:
17
+
18
+ ```bash
19
+ node scripts/nordrelay.mjs start
20
+ node scripts/nordrelay.mjs status
21
+ node scripts/nordrelay.mjs stop
22
+ ```
23
+
24
+ The bridge needs `TELEGRAM_BOT_TOKEN` and either `TELEGRAM_ALLOWED_USER_IDS`, `TELEGRAM_ALLOWED_CHAT_IDS`, or `TELEGRAM_ALLOW_ANY_CHAT=1`.
25
+
26
+ Prefer `start` for normal use. Use `foreground` only when debugging connection problems, because it keeps the current command running. If the runtime is missing, run `npm install` and `npm run build` in the repository root.