@nordbyte/nordrelay 0.4.1 → 0.5.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 (57) hide show
  1. package/.env.example +155 -64
  2. package/README.md +81 -65
  3. package/dist/access-control.js +126 -115
  4. package/dist/agent-updates.js +62 -9
  5. package/dist/bot-rendering.js +838 -0
  6. package/dist/bot-ui.js +1 -0
  7. package/dist/bot.js +342 -2498
  8. package/dist/channel-actions.js +8 -8
  9. package/dist/channel-runtime.js +89 -0
  10. package/dist/config-metadata.js +238 -0
  11. package/dist/config.js +0 -58
  12. package/dist/index.js +8 -0
  13. package/dist/operations.js +63 -9
  14. package/dist/relay-artifact-service.js +126 -0
  15. package/dist/relay-external-activity-monitor.js +216 -0
  16. package/dist/relay-queue-service.js +66 -0
  17. package/dist/relay-runtime-types.js +1 -0
  18. package/dist/relay-runtime.js +96 -354
  19. package/dist/settings-service.js +2 -117
  20. package/dist/support-bundle.js +205 -0
  21. package/dist/telegram-access-commands.js +123 -0
  22. package/dist/telegram-access-middleware.js +129 -0
  23. package/dist/telegram-agent-commands.js +212 -0
  24. package/dist/telegram-artifact-commands.js +139 -0
  25. package/dist/telegram-channel-runtime.js +132 -0
  26. package/dist/telegram-command-menu.js +55 -0
  27. package/dist/telegram-command-types.js +1 -0
  28. package/dist/telegram-diagnostics-command.js +102 -0
  29. package/dist/telegram-general-commands.js +52 -0
  30. package/dist/telegram-operational-commands.js +153 -0
  31. package/dist/telegram-output.js +216 -0
  32. package/dist/telegram-preference-commands.js +198 -0
  33. package/dist/telegram-queue-commands.js +278 -0
  34. package/dist/telegram-support-command.js +53 -0
  35. package/dist/telegram-update-commands.js +93 -0
  36. package/dist/user-management.js +708 -0
  37. package/dist/web-api-contract.js +104 -0
  38. package/dist/web-api-types.js +1 -0
  39. package/dist/web-dashboard-access-routes.js +163 -0
  40. package/dist/web-dashboard-artifact-routes.js +65 -0
  41. package/dist/web-dashboard-assets.js +35 -2
  42. package/dist/web-dashboard-http.js +143 -0
  43. package/dist/web-dashboard-pages.js +257 -0
  44. package/dist/web-dashboard-runtime-routes.js +92 -0
  45. package/dist/web-dashboard-session-routes.js +209 -0
  46. package/dist/web-dashboard-ui.js +14 -14
  47. package/dist/web-dashboard.js +330 -707
  48. package/dist/webui-assets/dashboard.css +989 -0
  49. package/dist/webui-assets/dashboard.js +1750 -0
  50. package/dist/zip-writer.js +83 -0
  51. package/package.json +13 -4
  52. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  53. package/plugins/nordrelay/commands/remote.md +1 -1
  54. package/plugins/nordrelay/scripts/nordrelay.mjs +227 -78
  55. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
  56. package/dist/web-dashboard-client.js +0 -275
  57. package/dist/web-dashboard-style.js +0 -9
@@ -52,13 +52,13 @@ export function renderAgentUpdatePickerAction(descriptors) {
52
52
  "Agent updates:",
53
53
  ...available.map((descriptor) => `${descriptor.label}: /update ${descriptor.id}`),
54
54
  "",
55
- "Use /update jobs to list running and recent agent updates.",
55
+ "Use /update install <agent> for missing CLIs and /update jobs to list running and recent agent updates.",
56
56
  ].join("\n"),
57
57
  html: [
58
58
  "<b>Agent updates:</b>",
59
59
  ...available.map((descriptor) => `<b>${escapeHTML(descriptor.label)}:</b> <code>/update ${escapeHTML(descriptor.id)}</code>`),
60
60
  "",
61
- "Use <code>/update jobs</code> to list running and recent agent updates.",
61
+ "Use <code>/update install &lt;agent&gt;</code> for missing CLIs and <code>/update jobs</code> to list running and recent agent updates.",
62
62
  ].join("\n"),
63
63
  buttons,
64
64
  };
@@ -86,13 +86,13 @@ export function renderAgentUpdateJobsAction(jobs) {
86
86
  return {
87
87
  plain: [
88
88
  "Agent update jobs:",
89
- ...limited.map((job) => `${job.id}: ${job.agentLabel} · ${job.status} · ${formatLocalDateTime(new Date(job.updatedAt))}`),
89
+ ...limited.map((job) => `${job.id}: ${job.agentLabel} ${job.operation ?? "update"} · ${job.status} · ${formatLocalDateTime(new Date(job.updatedAt))}`),
90
90
  "",
91
91
  "Use /update log <id>, /update cancel <id>, or /update input <id> <text>.",
92
92
  ].join("\n"),
93
93
  html: [
94
94
  "<b>Agent update jobs:</b>",
95
- ...limited.map((job) => `<code>${escapeHTML(job.id)}</code> ${escapeHTML(job.agentLabel)} · <b>${escapeHTML(job.status)}</b> · <code>${escapeHTML(formatLocalDateTime(new Date(job.updatedAt)))}</code>`),
95
+ ...limited.map((job) => `<code>${escapeHTML(job.id)}</code> ${escapeHTML(job.agentLabel)} ${escapeHTML(job.operation ?? "update")} · <b>${escapeHTML(job.status)}</b> · <code>${escapeHTML(formatLocalDateTime(new Date(job.updatedAt)))}</code>`),
96
96
  "",
97
97
  "Use <code>/update log &lt;id&gt;</code>, <code>/update cancel &lt;id&gt;</code>, or <code>/update input &lt;id&gt; &lt;text&gt;</code>.",
98
98
  ].join("\n"),
@@ -106,7 +106,7 @@ export function renderAgentUpdateJobAction(job) {
106
106
  const tail = trimLine(job.outputTail || "(waiting for output)", 1200);
107
107
  return {
108
108
  plain: [
109
- `${job.agentLabel} update ${job.status}.`,
109
+ `${job.agentLabel} ${job.operation ?? "update"} ${job.status}.`,
110
110
  `ID: ${job.id}`,
111
111
  `Method: ${job.method}`,
112
112
  `Command: ${command}`,
@@ -120,7 +120,7 @@ export function renderAgentUpdateJobAction(job) {
120
120
  tail,
121
121
  ].filter(Boolean).join("\n"),
122
122
  html: [
123
- `<b>${escapeHTML(job.agentLabel)} update ${escapeHTML(job.status)}.</b>`,
123
+ `<b>${escapeHTML(job.agentLabel)} ${escapeHTML(job.operation ?? "update")} ${escapeHTML(job.status)}.</b>`,
124
124
  `<b>ID:</b> <code>${escapeHTML(job.id)}</code>`,
125
125
  `<b>Method:</b> <code>${escapeHTML(job.method)}</code>`,
126
126
  `<b>Command:</b> <code>${escapeHTML(command)}</code>`,
@@ -145,7 +145,7 @@ export function renderAgentUpdateLogAction(result) {
145
145
  const tail = trimLine(result.plain || "(empty)", 3000);
146
146
  return {
147
147
  plain: [
148
- `${result.job.agentLabel} update log`,
148
+ `${result.job.agentLabel} ${result.job.operation ?? "update"} log`,
149
149
  `ID: ${result.job.id}`,
150
150
  `Status: ${result.job.status}`,
151
151
  `File: ${result.job.logPath}`,
@@ -153,7 +153,7 @@ export function renderAgentUpdateLogAction(result) {
153
153
  tail,
154
154
  ].join("\n"),
155
155
  html: [
156
- `<b>${escapeHTML(result.job.agentLabel)} update log</b>`,
156
+ `<b>${escapeHTML(result.job.agentLabel)} ${escapeHTML(result.job.operation ?? "update")} log</b>`,
157
157
  `<b>ID:</b> <code>${escapeHTML(result.job.id)}</code>`,
158
158
  `<b>Status:</b> <code>${escapeHTML(result.job.status)}</code>`,
159
159
  `<b>File:</b> <code>${escapeHTML(result.job.logPath)}</code>`,
@@ -0,0 +1,89 @@
1
+ export class ChannelCommandRouter {
2
+ handlers = new Map();
3
+ command(name, handler) {
4
+ const normalized = normalizeCommandName(name);
5
+ if (!normalized) {
6
+ throw new Error("Channel command name is required.");
7
+ }
8
+ this.handlers.set(normalized, handler);
9
+ return this;
10
+ }
11
+ async dispatch(message) {
12
+ const parsed = parseChannelCommand(message.text ?? "");
13
+ if (!parsed) {
14
+ return { matched: false };
15
+ }
16
+ const handler = this.handlers.get(parsed.command);
17
+ if (!handler) {
18
+ return { matched: false, command: parsed.command };
19
+ }
20
+ const response = await handler({
21
+ ...message,
22
+ text: parsed.argument,
23
+ });
24
+ return {
25
+ matched: true,
26
+ command: parsed.command,
27
+ response: response ?? undefined,
28
+ };
29
+ }
30
+ }
31
+ export async function deliverChannelAction(runtime, context, response) {
32
+ return runtime.sendMessage(context, {
33
+ text: response.html,
34
+ fallbackText: response.plain,
35
+ parseMode: "html",
36
+ buttons: response.buttons,
37
+ });
38
+ }
39
+ export function parseChannelCommand(text) {
40
+ const match = text.trimStart().match(/^\/([a-zA-Z0-9_-]+)(?:@\w+)?(?:\s+([\s\S]*))?$/);
41
+ if (!match?.[1]) {
42
+ return null;
43
+ }
44
+ return {
45
+ command: normalizeCommandName(match[1]),
46
+ argument: match[2]?.trim() ?? "",
47
+ };
48
+ }
49
+ export class InMemoryChannelRuntime {
50
+ descriptor;
51
+ capabilities;
52
+ id;
53
+ label;
54
+ sentMessages = [];
55
+ editedMessages = [];
56
+ typingContexts = [];
57
+ sentFiles = [];
58
+ constructor(descriptor) {
59
+ this.descriptor = descriptor;
60
+ this.id = descriptor.id;
61
+ this.label = descriptor.label;
62
+ this.capabilities = new Set(descriptor.capabilities);
63
+ }
64
+ describe() {
65
+ return {
66
+ ...this.descriptor,
67
+ capabilities: [...this.descriptor.capabilities],
68
+ };
69
+ }
70
+ async sendMessage(context, message) {
71
+ const messageId = `${this.id}-message-${this.sentMessages.length + 1}`;
72
+ this.sentMessages.push({ context, message, messageId });
73
+ return { messageId };
74
+ }
75
+ async editMessage(context, messageId, message) {
76
+ this.editedMessages.push({ context, messageId, message });
77
+ }
78
+ async sendTyping(context) {
79
+ this.typingContexts.push(context);
80
+ }
81
+ async sendFile(context, file) {
82
+ const messageId = `${this.id}-file-${this.sentFiles.length + 1}`;
83
+ this.sentFiles.push({ context, file, messageId });
84
+ return { messageId };
85
+ }
86
+ }
87
+ function normalizeCommandName(name) {
88
+ return name.trim().replace(/^\//, "").toLowerCase();
89
+ }
@@ -0,0 +1,238 @@
1
+ export const SECRET_KEYS = new Set([
2
+ "TELEGRAM_BOT_TOKEN",
3
+ "CODEX_API_KEY",
4
+ "HERMES_API_KEY",
5
+ "OPENCLAW_GATEWAY_TOKEN",
6
+ "OPENCLAW_GATEWAY_PASSWORD",
7
+ "OPENAI_API_KEY",
8
+ "TELEGRAM_WEBHOOK_SECRET",
9
+ ]);
10
+ export const SETTING_DEFINITIONS = [
11
+ setting("TELEGRAM_BOT_TOKEN", "Telegram bot token", "Telegram", "secret", "BotFather token.", true),
12
+ setting("TELEGRAM_TRANSPORT", "Telegram transport", "Telegram", "string", "polling or webhook.", true, ["polling", "webhook"]),
13
+ setting("TELEGRAM_WEBHOOK_URL", "Webhook public URL", "Telegram", "string", "Public base URL for webhook mode.", true),
14
+ setting("TELEGRAM_WEBHOOK_HOST", "Webhook bind host", "Telegram", "string", "Local webhook bind host.", true),
15
+ setting("TELEGRAM_WEBHOOK_PORT", "Webhook bind port", "Telegram", "number", "Local webhook bind port.", true),
16
+ setting("TELEGRAM_WEBHOOK_PATH", "Webhook path", "Telegram", "string", "Webhook request path.", true),
17
+ setting("TELEGRAM_WEBHOOK_SECRET", "Webhook secret", "Telegram", "secret", "Optional Telegram webhook secret token.", true),
18
+ setting("NORDRELAY_CODEX_ENABLED", "Enable Codex", "Agents", "boolean", "Allow Codex sessions.", true),
19
+ setting("NORDRELAY_PI_ENABLED", "Enable Pi", "Agents", "boolean", "Allow Pi sessions.", true),
20
+ setting("NORDRELAY_HERMES_ENABLED", "Enable Hermes", "Agents", "boolean", "Allow Hermes sessions through the Hermes API Server.", true),
21
+ setting("NORDRELAY_OPENCLAW_ENABLED", "Enable OpenClaw", "Agents", "boolean", "Allow OpenClaw sessions through the OpenClaw Gateway.", true),
22
+ setting("NORDRELAY_CLAUDE_CODE_ENABLED", "Enable Claude Code", "Agents", "boolean", "Allow Claude Code sessions through the Claude Agent SDK.", true),
23
+ setting("NORDRELAY_DEFAULT_AGENT", "Default agent", "Agents", "string", "codex, pi, hermes, openclaw, or claude-code.", true, ["codex", "pi", "hermes", "openclaw", "claude-code"]),
24
+ setting("CODEX_API_KEY", "Codex API key", "Codex", "secret", "Optional Codex SDK API key.", true),
25
+ setting("CODEX_CLI_PATH", "Codex CLI path", "Codex", "string", "Optional explicit Codex executable path.", true),
26
+ setting("CODEX_USE_BUNDLED_CLI", "Use bundled Codex CLI", "Codex", "boolean", "Force SDK-bundled CLI instead of host CLI.", true),
27
+ setting("CODEX_MODEL", "Default Codex model", "Codex", "string", "Default model for new Codex threads.", false),
28
+ setting("CODEX_SYNC_INTERVAL_MS", "Codex sync interval", "Codex", "number", "Local state sync interval.", true),
29
+ setting("CODEX_EXTERNAL_BUSY_CHECK_MS", "External busy check", "Codex", "number", "External CLI busy polling interval.", true),
30
+ setting("CODEX_EXTERNAL_BUSY_STALE_MS", "External busy stale timeout", "Codex", "number", "External CLI stale timeout.", true),
31
+ setting("CODEX_SANDBOX_MODE", "Codex sandbox mode", "Codex", "string", "read-only, workspace-write, or danger-full-access.", true, ["read-only", "workspace-write", "danger-full-access"]),
32
+ setting("CODEX_APPROVAL_POLICY", "Codex approval policy", "Codex", "string", "never, on-request, on-failure, or untrusted.", true, ["never", "on-request", "on-failure", "untrusted"]),
33
+ setting("CODEX_LAUNCH_PROFILES_JSON", "Launch profiles JSON", "Codex", "json", "Additional launch profile definitions.", true),
34
+ setting("CODEX_DEFAULT_LAUNCH_PROFILE", "Default launch profile", "Codex", "string", "Launch profile ID used by default.", true),
35
+ setting("ENABLE_UNSAFE_LAUNCH_PROFILES", "Enable unsafe profiles", "Codex", "boolean", "Expose danger-full-access profiles.", true),
36
+ setting("PI_CLI_PATH", "Pi CLI path", "Pi", "string", "Optional Pi executable path.", true),
37
+ setting("PI_SESSION_DIR", "Pi session dir", "Pi", "string", "Optional Pi session directory.", true),
38
+ setting("PI_DEFAULT_MODEL", "Default Pi model", "Pi", "string", "Default Pi model slug.", false),
39
+ setting("PI_DEFAULT_THINKING", "Default Pi thinking", "Pi", "string", "off, minimal, low, medium, high, or xhigh.", false, ["off", "minimal", "low", "medium", "high", "xhigh"]),
40
+ setting("PI_DEFAULT_PROFILE", "Default Pi profile", "Pi", "string", "default, readonly, no-tools, offline, or safe-offline.", true, ["default", "readonly", "no-tools", "offline", "safe-offline"]),
41
+ setting("HERMES_CLI_PATH", "Hermes CLI path", "Hermes", "string", "Optional Hermes executable path.", true),
42
+ setting("HERMES_HOME", "Hermes home", "Hermes", "string", "Optional Hermes home directory. Defaults to ~/.hermes.", true),
43
+ setting("HERMES_STATE_DB_PATH", "Hermes state DB path", "Hermes", "string", "Optional explicit Hermes state.db path.", true),
44
+ setting("HERMES_API_BASE_URL", "Hermes API base URL", "Hermes", "string", "Hermes API Server base URL.", true),
45
+ setting("HERMES_API_KEY", "Hermes API key", "Hermes", "secret", "Bearer token for the Hermes API Server.", true),
46
+ setting("HERMES_DEFAULT_MODEL", "Default Hermes model", "Hermes", "string", "Default model label sent to Hermes API runs.", false),
47
+ setting("HERMES_DEFAULT_REASONING", "Default Hermes reasoning", "Hermes", "string", "none, minimal, low, medium, high, or xhigh.", false, ["none", "minimal", "low", "medium", "high", "xhigh"]),
48
+ setting("HERMES_DEFAULT_PROFILE", "Default Hermes profile", "Hermes", "string", "default, safe, readonly, or yolo.", true, ["default", "safe", "readonly", "yolo"]),
49
+ setting("OPENCLAW_CLI_PATH", "OpenClaw CLI path", "OpenClaw", "string", "Optional OpenClaw executable path.", true),
50
+ setting("OPENCLAW_GATEWAY_URL", "OpenClaw Gateway URL", "OpenClaw", "string", "OpenClaw Gateway WebSocket URL.", true),
51
+ setting("OPENCLAW_GATEWAY_TOKEN", "OpenClaw Gateway token", "OpenClaw", "secret", "Shared-secret token for the OpenClaw Gateway.", true),
52
+ setting("OPENCLAW_GATEWAY_PASSWORD", "OpenClaw Gateway password", "OpenClaw", "secret", "Shared-secret password for the OpenClaw Gateway.", true),
53
+ setting("OPENCLAW_AGENT_ID", "OpenClaw agent ID", "OpenClaw", "string", "Configured OpenClaw agent id, for example main or work.", false),
54
+ setting("OPENCLAW_HOME", "OpenClaw home", "OpenClaw", "string", "Optional OpenClaw home directory. Defaults to ~/.openclaw.", true),
55
+ setting("OPENCLAW_STATE_DIR", "OpenClaw state dir", "OpenClaw", "string", "Optional OpenClaw state directory.", true),
56
+ setting("OPENCLAW_DEFAULT_MODEL", "Default OpenClaw model", "OpenClaw", "string", "Default OpenClaw model id.", false),
57
+ setting("OPENCLAW_DEFAULT_THINKING", "Default OpenClaw thinking", "OpenClaw", "string", "off, minimal, low, medium, high, or xhigh.", false, ["off", "minimal", "low", "medium", "high", "xhigh"]),
58
+ setting("OPENCLAW_DEFAULT_PROFILE", "Default OpenClaw profile", "OpenClaw", "string", "default, safe, readonly, local, or deliver.", true, ["default", "safe", "readonly", "local", "deliver"]),
59
+ setting("CLAUDE_CODE_CLI_PATH", "Claude Code CLI path", "Claude Code", "string", "Optional Claude Code executable path. Defaults to claude on PATH or the SDK bundled runtime.", true),
60
+ setting("CLAUDE_CONFIG_DIR", "Claude config dir", "Claude Code", "string", "Optional Claude config directory. Defaults to ~/.claude.", true),
61
+ setting("CLAUDE_CODE_DEFAULT_MODEL", "Default Claude Code model", "Claude Code", "string", "Default Claude Code model alias or model id.", false),
62
+ setting("CLAUDE_CODE_DEFAULT_EFFORT", "Default Claude Code effort", "Claude Code", "string", "off, low, medium, high, or xhigh.", false, ["off", "low", "medium", "high", "xhigh"]),
63
+ setting("CLAUDE_CODE_DEFAULT_PROFILE", "Default Claude Code profile", "Claude Code", "string", "default, accept-edits, plan, readonly, no-tools, or bypass-permissions.", true, ["default", "accept-edits", "plan", "readonly", "no-tools", "bypass-permissions"]),
64
+ setting("CLAUDE_CODE_MAX_TURNS", "Claude Code max turns", "Claude Code", "number", "Maximum agentic turns for each Claude Code prompt.", false),
65
+ setting("CONNECTOR_LOG_FORMAT", "Log format", "Operations", "string", "text or json.", true, ["text", "json"]),
66
+ setting("TOOL_VERBOSITY", "Tool verbosity", "Operations", "string", "all, summary, errors-only, or none.", false, ["all", "summary", "errors-only", "none"]),
67
+ setting("SHOW_TURN_TOKEN_USAGE", "Show turn token usage", "Operations", "boolean", "Append per-turn token usage.", false),
68
+ setting("ENABLE_TELEGRAM_LOGIN", "Enable Telegram login", "Operations", "boolean", "Allow /login and /logout.", true),
69
+ setting("ENABLE_TELEGRAM_REACTIONS", "Enable Telegram reactions", "Operations", "boolean", "Send Telegram reactions.", true),
70
+ setting("TELEGRAM_RATE_LIMIT_MIN_INTERVAL_MS", "Telegram send interval", "Operations", "number", "Minimum send interval.", true),
71
+ setting("TELEGRAM_EDIT_MIN_INTERVAL_MS", "Telegram edit interval", "Operations", "number", "Minimum edit interval.", true),
72
+ setting("TELEGRAM_CLI_MIRROR_MODE", "CLI mirror mode", "Operations", "string", "off, status, final, or full.", false, ["off", "status", "final", "full"]),
73
+ setting("TELEGRAM_CLI_MIRROR_MIN_UPDATE_MS", "CLI mirror update interval", "Operations", "number", "Minimum mirrored edit interval.", true),
74
+ setting("TELEGRAM_NOTIFY_MODE", "Notify mode", "Operations", "string", "off, minimal, or all.", false, ["off", "minimal", "all"]),
75
+ setting("TELEGRAM_QUIET_HOURS", "Quiet hours", "Operations", "string", "HH-HH or blank.", false),
76
+ setting("TELEGRAM_REDACT_PATTERNS", "Redaction patterns", "Operations", "list", "Additional comma-separated regex patterns.", true),
77
+ setting("NORDRELAY_UPDATE_METHOD", "Update method", "Operations", "string", "auto, npm, or git.", true, ["auto", "npm", "git"]),
78
+ setting("MAX_FILE_SIZE", "Max file size", "Artifacts", "number", "Max inbound/outbound file size.", true),
79
+ setting("ARTIFACT_RETENTION_DAYS", "Artifact retention days", "Artifacts", "number", "Days before pruning.", true),
80
+ setting("ARTIFACT_MAX_TURNS", "Max artifact turns", "Artifacts", "number", "Maximum artifact turns retained.", true),
81
+ setting("ARTIFACT_MAX_INBOX_DIRS", "Max inbox dirs", "Artifacts", "number", "Maximum inbox dirs retained.", true),
82
+ setting("ARTIFACT_IGNORE_DIRS", "Artifact ignore dirs", "Artifacts", "list", "Extra ignored dirs or relative paths.", true),
83
+ setting("ARTIFACT_IGNORE_GLOBS", "Artifact ignore globs", "Artifacts", "list", "Extra ignored glob patterns.", true),
84
+ setting("TELEGRAM_AUTO_SEND_ARTIFACTS", "Auto-send artifacts", "Artifacts", "boolean", "Automatically send artifact files.", false),
85
+ setting("WORKSPACE_ALLOWED_ROOTS", "Workspace allowed roots", "Workspace", "list", "Restrict selectable workspaces.", true),
86
+ setting("WORKSPACE_WARN_ROOTS", "Workspace warn roots", "Workspace", "list", "Warn for broad workspace roots.", true),
87
+ setting("NORDRELAY_STATE_BACKEND", "State backend", "Workspace", "string", "json or sqlite.", true, ["json", "sqlite"]),
88
+ setting("NORDRELAY_AUDIT_MAX_EVENTS", "Audit max events", "Workspace", "number", "Retained audit events.", true),
89
+ setting("NORDRELAY_SESSION_LOCK_TTL_MS", "Session lock TTL", "Workspace", "number", "Write-lock TTL.", true),
90
+ setting("NORDRELAY_VERSION_CACHE_TTL_MS", "Version cache TTL", "Workspace", "number", "NPM version cache TTL.", true),
91
+ setting("OPENAI_API_KEY", "OpenAI API key", "Voice", "secret", "Whisper fallback API key.", true),
92
+ setting("VOICE_PREFERRED_BACKEND", "Voice backend", "Voice", "string", "auto, parakeet, faster-whisper, or openai.", false, ["auto", "parakeet", "faster-whisper", "openai"]),
93
+ setting("VOICE_DEFAULT_LANGUAGE", "Voice language", "Voice", "string", "Default transcription language.", false),
94
+ setting("VOICE_TRANSCRIBE_ONLY", "Voice transcribe only", "Voice", "boolean", "Do not send voice transcripts as prompts.", false),
95
+ setting("FASTER_WHISPER_PYTHON", "faster-whisper Python", "Voice", "string", "Python executable.", true),
96
+ setting("FASTER_WHISPER_MODEL", "faster-whisper model", "Voice", "string", "Model name.", true),
97
+ setting("FASTER_WHISPER_DEVICE", "faster-whisper device", "Voice", "string", "cpu, cuda, etc.", true),
98
+ setting("FASTER_WHISPER_COMPUTE_TYPE", "faster-whisper compute type", "Voice", "string", "int8, float16, etc.", true),
99
+ setting("FASTER_WHISPER_LANGUAGE", "faster-whisper language", "Voice", "string", "Fixed transcription language.", true),
100
+ setting("FASTER_WHISPER_TIMEOUT_MS", "faster-whisper timeout", "Voice", "number", "Transcription timeout.", true),
101
+ setting("NORDRELAY_DASHBOARD_HOST", "Dashboard host", "Dashboard", "string", "WebUI bind host.", true),
102
+ setting("NORDRELAY_DASHBOARD_PORT", "Dashboard port", "Dashboard", "number", "WebUI bind port.", true),
103
+ setting("NORDRELAY_ENV_FILE", "Env file path", "Dashboard", "string", "Optional explicit env-file path used by the CLI wrapper and dashboard.", true),
104
+ ];
105
+ const EXAMPLE_VALUES = {
106
+ "TELEGRAM_BOT_TOKEN": "123456789:replace-me",
107
+ "NORDRELAY_CODEX_ENABLED": "true",
108
+ "NORDRELAY_PI_ENABLED": "false",
109
+ "NORDRELAY_HERMES_ENABLED": "false",
110
+ "NORDRELAY_OPENCLAW_ENABLED": "false",
111
+ "NORDRELAY_CLAUDE_CODE_ENABLED": "false",
112
+ "NORDRELAY_DEFAULT_AGENT": "codex",
113
+ "CODEX_API_KEY": "",
114
+ "CODEX_CLI_PATH": "",
115
+ "CODEX_USE_BUNDLED_CLI": "false",
116
+ "CODEX_MODEL": "",
117
+ "CODEX_SYNC_INTERVAL_MS": "10000",
118
+ "CODEX_EXTERNAL_BUSY_CHECK_MS": "5000",
119
+ "CODEX_EXTERNAL_BUSY_STALE_MS": "300000",
120
+ "CODEX_SANDBOX_MODE": "workspace-write",
121
+ "CODEX_APPROVAL_POLICY": "never",
122
+ "CODEX_LAUNCH_PROFILES_JSON": "",
123
+ "CODEX_DEFAULT_LAUNCH_PROFILE": "default",
124
+ "ENABLE_UNSAFE_LAUNCH_PROFILES": "false",
125
+ "PI_CLI_PATH": "",
126
+ "PI_SESSION_DIR": "",
127
+ "PI_DEFAULT_MODEL": "",
128
+ "PI_DEFAULT_THINKING": "medium",
129
+ "PI_DEFAULT_PROFILE": "default",
130
+ "HERMES_CLI_PATH": "",
131
+ "HERMES_HOME": "",
132
+ "HERMES_STATE_DB_PATH": "",
133
+ "HERMES_API_BASE_URL": "http://127.0.0.1:8642",
134
+ "HERMES_API_KEY": "",
135
+ "HERMES_DEFAULT_MODEL": "",
136
+ "HERMES_DEFAULT_REASONING": "",
137
+ "HERMES_DEFAULT_PROFILE": "default",
138
+ "OPENCLAW_CLI_PATH": "",
139
+ "OPENCLAW_GATEWAY_URL": "ws://127.0.0.1:18789",
140
+ "OPENCLAW_GATEWAY_TOKEN": "",
141
+ "OPENCLAW_GATEWAY_PASSWORD": "",
142
+ "OPENCLAW_AGENT_ID": "main",
143
+ "OPENCLAW_HOME": "",
144
+ "OPENCLAW_STATE_DIR": "",
145
+ "OPENCLAW_DEFAULT_MODEL": "",
146
+ "OPENCLAW_DEFAULT_THINKING": "",
147
+ "OPENCLAW_DEFAULT_PROFILE": "default",
148
+ "CLAUDE_CODE_CLI_PATH": "",
149
+ "CLAUDE_CONFIG_DIR": "",
150
+ "CLAUDE_CODE_DEFAULT_MODEL": "",
151
+ "CLAUDE_CODE_DEFAULT_EFFORT": "",
152
+ "CLAUDE_CODE_DEFAULT_PROFILE": "default",
153
+ "CLAUDE_CODE_MAX_TURNS": "100",
154
+ "CONNECTOR_LOG_FORMAT": "text",
155
+ "TOOL_VERBOSITY": "summary",
156
+ "SHOW_TURN_TOKEN_USAGE": "false",
157
+ "ENABLE_TELEGRAM_LOGIN": "true",
158
+ "ENABLE_TELEGRAM_REACTIONS": "false",
159
+ "TELEGRAM_RATE_LIMIT_MIN_INTERVAL_MS": "80",
160
+ "TELEGRAM_EDIT_MIN_INTERVAL_MS": "1200",
161
+ "TELEGRAM_TRANSPORT": "polling",
162
+ "TELEGRAM_WEBHOOK_URL": "",
163
+ "TELEGRAM_WEBHOOK_HOST": "127.0.0.1",
164
+ "TELEGRAM_WEBHOOK_PORT": "8080",
165
+ "TELEGRAM_WEBHOOK_PATH": "/telegram/webhook",
166
+ "TELEGRAM_WEBHOOK_SECRET": "",
167
+ "TELEGRAM_CLI_MIRROR_MODE": "status",
168
+ "TELEGRAM_CLI_MIRROR_MIN_UPDATE_MS": "4000",
169
+ "TELEGRAM_NOTIFY_MODE": "minimal",
170
+ "TELEGRAM_QUIET_HOURS": "",
171
+ "TELEGRAM_REDACT_PATTERNS": "",
172
+ "MAX_FILE_SIZE": "20971520",
173
+ "ARTIFACT_RETENTION_DAYS": "7",
174
+ "ARTIFACT_MAX_TURNS": "30",
175
+ "ARTIFACT_MAX_INBOX_DIRS": "30",
176
+ "ARTIFACT_IGNORE_DIRS": "",
177
+ "ARTIFACT_IGNORE_GLOBS": "",
178
+ "TELEGRAM_AUTO_SEND_ARTIFACTS": "false",
179
+ "NORDRELAY_STATE_BACKEND": "json",
180
+ "NORDRELAY_AUDIT_MAX_EVENTS": "1000",
181
+ "NORDRELAY_SESSION_LOCK_TTL_MS": "1800000",
182
+ "NORDRELAY_VERSION_CACHE_TTL_MS": "3600000",
183
+ "NORDRELAY_DASHBOARD_HOST": "127.0.0.1",
184
+ "NORDRELAY_DASHBOARD_PORT": "31878",
185
+ "NORDRELAY_ENV_FILE": "",
186
+ "WORKSPACE_ALLOWED_ROOTS": "",
187
+ "WORKSPACE_WARN_ROOTS": "",
188
+ "OPENAI_API_KEY": "",
189
+ "VOICE_PREFERRED_BACKEND": "auto",
190
+ "VOICE_DEFAULT_LANGUAGE": "",
191
+ "VOICE_TRANSCRIBE_ONLY": "false",
192
+ "FASTER_WHISPER_PYTHON": ".venv/bin/python",
193
+ "FASTER_WHISPER_MODEL": "base",
194
+ "FASTER_WHISPER_DEVICE": "cpu",
195
+ "FASTER_WHISPER_COMPUTE_TYPE": "int8",
196
+ "FASTER_WHISPER_LANGUAGE": "",
197
+ "FASTER_WHISPER_TIMEOUT_MS": "600000",
198
+ };
199
+ const GROUP_INTROS = {
200
+ Telegram: "Required Telegram bot and transport settings.",
201
+ Agents: "Agent access. Codex is enabled by default; Pi, Hermes, OpenClaw, and Claude Code are opt-in.",
202
+ Codex: "Codex defaults for newly created or reattached sessions.",
203
+ Pi: "Pi coding agent defaults.",
204
+ Hermes: "Hermes Agent defaults. Hermes uses the Hermes API Server.",
205
+ OpenClaw: "OpenClaw Agent defaults. OpenClaw uses the OpenClaw Gateway WebSocket RPC endpoint.",
206
+ "Claude Code": "Claude Code defaults. NordRelay uses the Claude Agent SDK and the host claude CLI when present.",
207
+ Operations: "Runtime output, logging, update, and Telegram behavior controls.",
208
+ Artifacts: "File, artifact, and retention controls.",
209
+ Workspace: "State and workspace guardrails.",
210
+ Voice: "Optional voice transcription settings.",
211
+ Dashboard: "Local WebUI dashboard. User login is required for every page, API route, SSE stream, artifact download, and health endpoint.",
212
+ };
213
+ export function envExampleValue(key) {
214
+ return EXAMPLE_VALUES[key] ?? "";
215
+ }
216
+ export function renderEnvExample() {
217
+ const lines = [
218
+ "# NordRelay runtime config example.",
219
+ "# Access is managed with NordRelay users, groups, linked Telegram identities, and enabled Telegram group chats.",
220
+ "# Create the first admin with `nordrelay init` or `nordrelay user create-admin`.",
221
+ ];
222
+ let currentGroup = "";
223
+ for (const definition of SETTING_DEFINITIONS) {
224
+ if (definition.group !== currentGroup) {
225
+ currentGroup = definition.group;
226
+ lines.push('', `# ${currentGroup}`, `# ${GROUP_INTROS[currentGroup] ?? definition.description}`);
227
+ }
228
+ lines.push(`# ${definition.description}`);
229
+ if (definition.options?.length) {
230
+ lines.push(`# Options: ${definition.options.join(", ")}`);
231
+ }
232
+ lines.push(`${definition.key}=${envExampleValue(definition.key)}`);
233
+ }
234
+ return `${lines.join('\n')}\n`;
235
+ }
236
+ function setting(key, label, group, kind, description, restartRequired, options) {
237
+ return { key, label, group, kind, description, restartRequired, options };
238
+ }
package/dist/config.js CHANGED
@@ -2,21 +2,10 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createBuiltinLaunchProfiles, createDefaultLaunchProfile, findLaunchProfile, isCodexApprovalPolicy, isCodexSandboxMode, parseLaunchProfilesJson, } from "./codex-launch.js";
4
4
  import { CLAUDE_CODE_EFFORT_LEVELS, HERMES_REASONING_EFFORTS, OPENCLAW_THINKING_LEVELS, isAgentId, PI_THINKING_LEVELS, } from "./agent.js";
5
- import { parseRolePoliciesJson, } from "./access-control.js";
6
5
  import { parseMirrorMode, parseNotifyMode, parseQuietHours, parseVoiceBackendPreference, } from "./bot-preferences.js";
7
6
  export function loadConfig() {
8
7
  loadEnvFile(path.resolve(process.cwd(), ".env"));
9
8
  const telegramBotToken = requireEnv("TELEGRAM_BOT_TOKEN");
10
- const telegramAllowAnyChat = parseBooleanEnv(optionalString(process.env.TELEGRAM_ALLOW_ANY_CHAT), false);
11
- const configuredAllowedUserIds = parseOptionalIdList(optionalString(process.env.TELEGRAM_ALLOWED_USER_IDS), "TELEGRAM_ALLOWED_USER_IDS", { positiveOnly: true });
12
- const telegramAllowedChatIds = parseOptionalIdList(optionalString(process.env.TELEGRAM_ALLOWED_CHAT_IDS), "TELEGRAM_ALLOWED_CHAT_IDS", { positiveOnly: false });
13
- const configuredAdminUserIds = parseOptionalIdList(optionalString(process.env.TELEGRAM_ADMIN_USER_IDS), "TELEGRAM_ADMIN_USER_IDS", { positiveOnly: true });
14
- ensureTelegramAdminIds(configuredAdminUserIds);
15
- const telegramAllowedUserIds = mergeUniqueIds(configuredAllowedUserIds, configuredAdminUserIds);
16
- const telegramReadOnlyUserIds = parseOptionalIdList(optionalString(process.env.TELEGRAM_READONLY_USER_IDS), "TELEGRAM_READONLY_USER_IDS", { positiveOnly: true });
17
- const telegramRolePolicies = parseRolePoliciesJson(optionalString(process.env.TELEGRAM_ROLE_POLICIES_JSON));
18
- ensureTelegramAllowlist(telegramAllowedUserIds, telegramAllowedChatIds, telegramAllowAnyChat);
19
- const telegramAdminUserIds = configuredAdminUserIds;
20
9
  const telegramRateLimitMinIntervalMs = parseNonNegativeIntegerEnv(optionalString(process.env.TELEGRAM_RATE_LIMIT_MIN_INTERVAL_MS), 80, "TELEGRAM_RATE_LIMIT_MIN_INTERVAL_MS");
21
10
  const telegramEditMinIntervalMs = parseNonNegativeIntegerEnv(optionalString(process.env.TELEGRAM_EDIT_MIN_INTERVAL_MS), 1_200, "TELEGRAM_EDIT_MIN_INTERVAL_MS");
22
11
  const telegramMirrorMode = parseMirrorMode(optionalString(process.env.TELEGRAM_CLI_MIRROR_MODE), "status");
@@ -102,16 +91,6 @@ export function loadConfig() {
102
91
  }
103
92
  return {
104
93
  telegramBotToken,
105
- telegramAllowedUserIds,
106
- telegramAllowedUserIdSet: new Set(telegramAllowedUserIds),
107
- telegramAllowedChatIds,
108
- telegramAllowedChatIdSet: new Set(telegramAllowedChatIds),
109
- telegramAdminUserIds,
110
- telegramAdminUserIdSet: new Set(telegramAdminUserIds),
111
- telegramReadOnlyUserIds,
112
- telegramReadOnlyUserIdSet: new Set(telegramReadOnlyUserIds),
113
- telegramRolePolicies,
114
- telegramAllowAnyChat,
115
94
  telegramRateLimitMinIntervalMs,
116
95
  telegramEditMinIntervalMs,
117
96
  telegramMirrorMode,
@@ -245,26 +224,6 @@ function optionalString(value) {
245
224
  const trimmed = value?.trim();
246
225
  return trimmed ? trimmed : undefined;
247
226
  }
248
- function parseOptionalIdList(raw, envName, options) {
249
- if (!raw) {
250
- return [];
251
- }
252
- const ids = raw
253
- .split(",")
254
- .map((value) => value.trim())
255
- .filter(Boolean)
256
- .map((value) => {
257
- const parsed = Number(value);
258
- if (!Number.isInteger(parsed) || (options.positiveOnly ? parsed <= 0 : parsed === 0)) {
259
- throw new Error(`Invalid Telegram id in ${envName}: ${value}`);
260
- }
261
- return parsed;
262
- });
263
- if (raw.trim() && ids.length === 0) {
264
- throw new Error(`${envName} must contain at least one id`);
265
- }
266
- return ids;
267
- }
268
227
  function parseOptionalStringList(raw) {
269
228
  if (!raw) {
270
229
  return [];
@@ -277,23 +236,6 @@ function parseOptionalStringList(raw) {
277
236
  function parsePathList(raw) {
278
237
  return parseOptionalStringList(raw).map((value) => path.resolve(value));
279
238
  }
280
- function ensureTelegramAllowlist(userIds, chatIds, allowAnyChat) {
281
- if (allowAnyChat) {
282
- return;
283
- }
284
- if (userIds.length > 0 || chatIds.length > 0) {
285
- return;
286
- }
287
- throw new Error("TELEGRAM_ALLOWED_USER_IDS or TELEGRAM_ALLOWED_CHAT_IDS must contain at least one id");
288
- }
289
- function ensureTelegramAdminIds(userIds) {
290
- if (userIds.length === 0) {
291
- throw new Error("TELEGRAM_ADMIN_USER_IDS must contain at least one id");
292
- }
293
- }
294
- function mergeUniqueIds(...groups) {
295
- return Array.from(new Set(groups.flat()));
296
- }
297
239
  function parseBooleanEnv(raw, defaultValue) {
298
240
  if (!raw) {
299
241
  return defaultValue;
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { checkPiAuthStatus } from "./pi-auth.js";
20
20
  import { describePiCli, resolvePiCli } from "./pi-cli.js";
21
21
  import { configureRedaction } from "./redaction.js";
22
22
  import { SessionRegistry } from "./session-registry.js";
23
+ import { UserStore } from "./user-management.js";
23
24
  let registry;
24
25
  let bot;
25
26
  let webhookServer;
@@ -33,6 +34,13 @@ try {
33
34
  bot = createBot(config, registry);
34
35
  await registerCommands(bot);
35
36
  console.log("NordRelay running");
37
+ const userStore = new UserStore();
38
+ if (userStore.hasAdminUser()) {
39
+ console.log("User management: admin user configured");
40
+ }
41
+ else {
42
+ console.warn("Warning: no NordRelay admin user exists. Run `nordrelay user create-admin` to enable WebUI and Telegram access.");
43
+ }
36
44
  const authStatus = await checkDefaultAgentAuth(config);
37
45
  console.log(`Auth (${agentLabel(config.defaultAgent)}): ${authStatus.authenticated ? "authenticated" : "not authenticated"} (${authStatus.method})`);
38
46
  if (!authStatus.authenticated) {