@nordbyte/nordrelay 0.8.0 → 0.8.2

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 (173) hide show
  1. package/.env.example +9 -0
  2. package/README.md +81 -1197
  3. package/dist/{access-control.js → access/access-control.js} +1 -1
  4. package/dist/{audit-log.js → access/audit-log.js} +2 -2
  5. package/dist/{session-locks.js → access/session-locks.js} +1 -1
  6. package/dist/{user-management.js → access/user-management.js} +1 -1
  7. package/dist/{claude-code-cli.js → agents/claude-code/claude-code-cli.js} +2 -2
  8. package/dist/{claude-code-session.js → agents/claude-code/claude-code-session.js} +1 -1
  9. package/dist/{codex-cli.js → agents/codex/codex-cli.js} +14 -5
  10. package/dist/{codex-session.js → agents/codex/codex-session.js} +2 -4
  11. package/dist/{hermes-cli.js → agents/hermes/hermes-cli.js} +2 -2
  12. package/dist/{hermes-launch.js → agents/hermes/hermes-launch.js} +1 -1
  13. package/dist/{hermes-session.js → agents/hermes/hermes-session.js} +1 -1
  14. package/dist/{openclaw-cli.js → agents/openclaw/openclaw-cli.js} +2 -2
  15. package/dist/{openclaw-launch.js → agents/openclaw/openclaw-launch.js} +1 -1
  16. package/dist/{openclaw-session.js → agents/openclaw/openclaw-session.js} +1 -1
  17. package/dist/{pi-cli.js → agents/pi/pi-cli.js} +2 -2
  18. package/dist/{pi-launch.js → agents/pi/pi-launch.js} +1 -1
  19. package/dist/{pi-session.js → agents/pi/pi-session.js} +1 -1
  20. package/dist/{adapter-conformance.js → agents/shared/adapter-conformance.js} +2 -2
  21. package/dist/{agent-activity.js → agents/shared/agent-activity.js} +5 -5
  22. package/dist/agents/shared/agent-auth-commands.js +30 -0
  23. package/dist/{agent-factory.js → agents/shared/agent-factory.js} +5 -5
  24. package/dist/{agent-feature-matrix.js → agents/shared/agent-feature-matrix.js} +2 -2
  25. package/dist/{agent-updates.js → agents/shared/agent-updates.js} +7 -7
  26. package/dist/{discord-artifacts.js → channels/discord/discord-artifacts.js} +4 -4
  27. package/dist/{discord-bot.js → channels/discord/discord-bot.js} +164 -424
  28. package/dist/{discord-channel-runtime.js → channels/discord/discord-channel-runtime.js} +2 -2
  29. package/dist/{discord-command-surface.js → channels/discord/discord-command-surface.js} +3 -3
  30. package/dist/{bot-rendering.js → channels/shared/bot-rendering.js} +6 -6
  31. package/dist/{channel-actions.js → channels/shared/channel-actions.js} +4 -4
  32. package/dist/channels/shared/channel-bridge-controller.js +69 -0
  33. package/dist/channels/shared/channel-cli-artifacts.js +51 -0
  34. package/dist/{channel-command-service.js → channels/shared/channel-command-service.js} +51 -28
  35. package/dist/channels/shared/channel-external-mirror-controller.js +193 -0
  36. package/dist/channels/shared/channel-external-monitor.js +52 -0
  37. package/dist/{channel-mirror-registry.js → channels/shared/channel-mirror-registry.js} +14 -6
  38. package/dist/{channel-peer-prompt.js → channels/shared/channel-peer-prompt.js} +3 -3
  39. package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +2 -2
  40. package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
  41. package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
  42. package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
  43. package/dist/{slack-bot.js → channels/slack/slack-bot.js} +159 -294
  44. package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
  45. package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
  46. package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
  47. package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
  48. package/dist/{bot.js → channels/telegram/bot.js} +178 -427
  49. package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
  50. package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
  51. package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
  52. package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
  53. package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
  54. package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
  55. package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
  56. package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
  57. package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
  58. package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
  59. package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
  60. package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
  61. package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
  62. package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
  63. package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
  64. package/dist/{config.js → core/config.js} +11 -3
  65. package/dist/index.js +27 -23
  66. package/dist/{peer-client.js → peers/peer-client.js} +57 -1
  67. package/dist/peers/peer-discovery-jobs.js +206 -0
  68. package/dist/peers/peer-discovery.js +223 -0
  69. package/dist/peers/peer-health-monitor.js +49 -0
  70. package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
  71. package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
  72. package/dist/{peer-server.js → peers/peer-server.js} +23 -6
  73. package/dist/{peer-store.js → peers/peer-store.js} +84 -11
  74. package/dist/{peer-types.js → peers/peer-types.js} +9 -0
  75. package/dist/peers/peer-web-proxy-contract.js +127 -0
  76. package/dist/{metrics.js → runtime/metrics.js} +5 -3
  77. package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
  78. package/dist/runtime/relay-auth-service.js +63 -0
  79. package/dist/runtime/relay-dashboard-service.js +139 -0
  80. package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +140 -53
  81. package/dist/runtime/relay-runtime-active-sessions.js +387 -0
  82. package/dist/runtime/relay-runtime-dashboard.js +201 -0
  83. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +307 -0
  84. package/dist/runtime/relay-runtime-sessions.js +623 -0
  85. package/dist/runtime/relay-runtime-types.js +1 -0
  86. package/dist/runtime/relay-runtime-updates-jobs.js +360 -0
  87. package/dist/runtime/relay-runtime.js +451 -0
  88. package/dist/runtime/runtime-cache.js +117 -0
  89. package/dist/{session-registry.js → state/session-registry.js} +3 -3
  90. package/dist/{operations.js → support/operations.js} +7 -7
  91. package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
  92. package/dist/{web-api-contract.js → web/web-api-contract.js} +17 -3
  93. package/dist/web/web-api-types.js +1 -0
  94. package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +2 -2
  95. package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +24 -2
  96. package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
  97. package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +37 -10
  98. package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +102 -7
  99. package/dist/web/web-dashboard-security.js +14 -0
  100. package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +12 -1
  101. package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
  102. package/dist/web/web-performance.js +60 -0
  103. package/dist/web/web-rate-limit.js +19 -0
  104. package/dist/{web-state.js → web/web-state.js} +74 -5
  105. package/dist/webui-assets/dashboard.css +171 -10
  106. package/dist/webui-assets/dashboard.js +515 -48
  107. package/dist/webui-assets/favicon.ico +0 -0
  108. package/dist/webui-assets/favicon.png +0 -0
  109. package/dist/webui-assets/logo.png +0 -0
  110. package/package.json +4 -3
  111. package/plugins/nordrelay/scripts/nordrelay.mjs +17 -5
  112. package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
  113. package/dist/relay-runtime.js +0 -1916
  114. package/dist/runtime-cache.js +0 -57
  115. /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
  116. /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
  117. /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
  118. /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
  119. /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
  120. /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
  121. /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
  122. /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
  123. /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
  124. /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
  125. /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
  126. /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
  127. /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
  128. /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
  129. /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
  130. /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
  131. /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
  132. /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
  133. /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
  134. /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
  135. /package/dist/{agent.js → agents/shared/agent.js} +0 -0
  136. /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
  137. /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
  138. /package/dist/{voice.js → artifacts/voice.js} +0 -0
  139. /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
  140. /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
  141. /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
  142. /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
  143. /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
  144. /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
  145. /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
  146. /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
  147. /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
  148. /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
  149. /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
  150. /package/dist/{activity-events.js → core/activity-events.js} +0 -0
  151. /package/dist/{error-messages.js → core/error-messages.js} +0 -0
  152. /package/dist/{format.js → core/format.js} +0 -0
  153. /package/dist/{logger.js → core/logger.js} +0 -0
  154. /package/dist/{redaction.js → core/redaction.js} +0 -0
  155. /package/dist/{settings-service.js → core/settings-service.js} +0 -0
  156. /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
  157. /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
  158. /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
  159. /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
  160. /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
  161. /package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +0 -0
  162. /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
  163. /package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +0 -0
  164. /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
  165. /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
  166. /package/dist/{job-store.js → state/job-store.js} +0 -0
  167. /package/dist/{persistence.js → state/persistence.js} +0 -0
  168. /package/dist/{prompt-store.js → state/prompt-store.js} +0 -0
  169. /package/dist/{state-backend.js → state/state-backend.js} +0 -0
  170. /package/dist/{zip-writer.js → support/zip-writer.js} +0 -0
  171. /package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +0 -0
  172. /package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +0 -0
  173. /package/dist/{web-dashboard-ui.js → web/web-dashboard-ui.js} +0 -0
@@ -0,0 +1,206 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { readJsonFileWithBackup, writeJsonFileAtomic } from "../state/persistence.js";
5
+ import { countDiscoveryTargets, discoverLanPeers } from "./peer-discovery.js";
6
+ const MAX_JOBS = 25;
7
+ const MAX_LOG_LINES = 300;
8
+ const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
9
+ export class PeerDiscoveryJobManager {
10
+ config;
11
+ jobs = new Map();
12
+ filePath;
13
+ constructor(config, home = process.env.NORDRELAY_HOME || DEFAULT_HOME) {
14
+ this.config = config;
15
+ this.filePath = path.join(home, "peer-discovery-jobs.json");
16
+ this.load();
17
+ }
18
+ list() {
19
+ return [...this.jobs.values()].map((entry) => cloneJob(entry.snapshot))
20
+ .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt));
21
+ }
22
+ get(id) {
23
+ const entry = this.jobs.get(id);
24
+ return entry ? cloneJob(entry.snapshot) : null;
25
+ }
26
+ log(id) {
27
+ return this.jobs.get(id)?.snapshot.log.join("\n") ?? "";
28
+ }
29
+ async start(input = {}) {
30
+ this.prune();
31
+ const controller = new AbortController();
32
+ const options = normalizeInput(this.config, input);
33
+ const id = randomUUID().replace(/-/g, "").slice(0, 12);
34
+ const snapshot = {
35
+ id,
36
+ status: "queued",
37
+ createdAt: new Date().toISOString(),
38
+ scanned: 0,
39
+ total: await countDiscoveryTargets(this.config, options).catch(() => 0),
40
+ candidates: [],
41
+ warnings: [],
42
+ log: [],
43
+ options,
44
+ };
45
+ const entry = { snapshot, controller };
46
+ this.jobs.set(id, entry);
47
+ this.append(entry, `Queued peer discovery job ${id}.`);
48
+ void this.run(entry).catch((error) => {
49
+ entry.snapshot.status = controller.signal.aborted ? "cancelled" : "failed";
50
+ entry.snapshot.error = error instanceof Error ? error.message : String(error);
51
+ entry.snapshot.completedAt = new Date().toISOString();
52
+ this.append(entry, entry.snapshot.error);
53
+ });
54
+ return cloneJob(snapshot);
55
+ }
56
+ cancel(id) {
57
+ const entry = this.jobs.get(id);
58
+ if (!entry)
59
+ return null;
60
+ if (entry.snapshot.status === "queued" || entry.snapshot.status === "running") {
61
+ entry.controller.abort();
62
+ entry.snapshot.status = "cancelled";
63
+ entry.snapshot.completedAt = new Date().toISOString();
64
+ this.append(entry, "Cancellation requested.");
65
+ }
66
+ return cloneJob(entry.snapshot);
67
+ }
68
+ async run(entry) {
69
+ entry.snapshot.status = "running";
70
+ entry.snapshot.startedAt = new Date().toISOString();
71
+ this.append(entry, `Scanning ${entry.snapshot.total} peer endpoint candidate(s).`);
72
+ const result = await discoverLanPeers(this.config, {
73
+ ...entry.snapshot.options,
74
+ signal: entry.controller.signal,
75
+ onProgress: (progress) => {
76
+ entry.snapshot.scanned = progress.scanned;
77
+ if (progress.candidate) {
78
+ entry.snapshot.candidates = mergeCandidates(entry.snapshot.candidates, progress.candidate);
79
+ this.append(entry, `Found ${progress.candidate.name || progress.candidate.host} at ${progress.candidate.url}.`);
80
+ }
81
+ else if (progress.scanned % 25 === 0 || progress.scanned === entry.snapshot.total) {
82
+ this.append(entry, `Scanned ${progress.scanned}/${progress.total}.`);
83
+ }
84
+ },
85
+ });
86
+ entry.snapshot.scanned = result.scanned;
87
+ entry.snapshot.candidates = result.candidates;
88
+ entry.snapshot.warnings = result.warnings;
89
+ entry.snapshot.status = entry.controller.signal.aborted ? "cancelled" : "completed";
90
+ entry.snapshot.completedAt = new Date().toISOString();
91
+ this.append(entry, `${entry.snapshot.status === "completed" ? "Completed" : "Cancelled"} with ${result.candidates.length} candidate(s).`);
92
+ for (const warning of result.warnings) {
93
+ this.append(entry, `Warning: ${warning}`);
94
+ }
95
+ }
96
+ append(entry, line) {
97
+ entry.snapshot.log.push(`[${new Date().toLocaleString()}] ${line}`);
98
+ if (entry.snapshot.log.length > MAX_LOG_LINES) {
99
+ entry.snapshot.log.splice(0, entry.snapshot.log.length - MAX_LOG_LINES);
100
+ }
101
+ this.save();
102
+ }
103
+ prune() {
104
+ const completed = this.list()
105
+ .filter((job) => job.status !== "running" && job.status !== "queued")
106
+ .slice(MAX_JOBS);
107
+ for (const job of completed) {
108
+ this.jobs.delete(job.id);
109
+ }
110
+ this.save();
111
+ }
112
+ load() {
113
+ const result = readJsonFileWithBackup(this.filePath);
114
+ const jobs = Array.isArray(result.value?.jobs) ? result.value.jobs : [];
115
+ let changed = false;
116
+ for (const job of jobs) {
117
+ const snapshot = normalizePersistedJob(job);
118
+ if (!snapshot)
119
+ continue;
120
+ if (snapshot.status === "queued" || snapshot.status === "running") {
121
+ snapshot.status = "failed";
122
+ snapshot.completedAt = new Date().toISOString();
123
+ snapshot.error = "Discovery job was interrupted by a NordRelay restart.";
124
+ snapshot.log = [
125
+ ...snapshot.log,
126
+ `[${new Date().toLocaleString()}] Discovery job was interrupted by a NordRelay restart.`,
127
+ ].slice(-MAX_LOG_LINES);
128
+ changed = true;
129
+ }
130
+ this.jobs.set(snapshot.id, { snapshot, controller: new AbortController() });
131
+ }
132
+ this.prune();
133
+ if (changed) {
134
+ this.save();
135
+ }
136
+ }
137
+ save() {
138
+ const jobs = this.list().slice(0, MAX_JOBS);
139
+ writeJsonFileAtomic(this.filePath, { version: 1, jobs });
140
+ }
141
+ }
142
+ function normalizePersistedJob(value) {
143
+ if (!value || typeof value !== "object" || Array.isArray(value))
144
+ return null;
145
+ const record = value;
146
+ if (typeof record.id !== "string" || typeof record.createdAt !== "string")
147
+ return null;
148
+ const status = typeof record.status === "string" && ["queued", "running", "completed", "failed", "cancelled"].includes(record.status)
149
+ ? record.status
150
+ : "failed";
151
+ const optionsRecord = record.options && typeof record.options === "object" && !Array.isArray(record.options)
152
+ ? record.options
153
+ : {};
154
+ return {
155
+ id: record.id,
156
+ status,
157
+ createdAt: record.createdAt,
158
+ startedAt: typeof record.startedAt === "string" ? record.startedAt : undefined,
159
+ completedAt: typeof record.completedAt === "string" ? record.completedAt : undefined,
160
+ scanned: integerField(record.scanned),
161
+ total: integerField(record.total),
162
+ candidates: Array.isArray(record.candidates) ? record.candidates : [],
163
+ warnings: Array.isArray(record.warnings) ? record.warnings.filter((item) => typeof item === "string") : [],
164
+ log: Array.isArray(record.log) ? record.log.filter((item) => typeof item === "string").slice(-MAX_LOG_LINES) : [],
165
+ error: typeof record.error === "string" ? record.error : undefined,
166
+ options: {
167
+ targets: Array.isArray(optionsRecord.targets) ? optionsRecord.targets.filter((item) => typeof item === "string") : [],
168
+ timeoutMs: integerField(optionsRecord.timeoutMs),
169
+ concurrency: integerField(optionsRecord.concurrency),
170
+ maxHosts: integerField(optionsRecord.maxHosts),
171
+ },
172
+ };
173
+ }
174
+ function integerField(value) {
175
+ const parsed = typeof value === "number" ? value : Number(value);
176
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
177
+ }
178
+ function normalizeInput(config, input) {
179
+ return {
180
+ targets: (input.targets ?? []).map((target) => target.trim()).filter(Boolean),
181
+ timeoutMs: clampInteger(input.timeoutMs, config.peerDiscoveryTimeoutMs, 100, 30_000),
182
+ concurrency: clampInteger(input.concurrency, 32, 1, 128),
183
+ maxHosts: clampInteger(input.maxHosts, 512, 1, 65_536),
184
+ };
185
+ }
186
+ function clampInteger(value, fallback, min, max) {
187
+ const parsed = Number(value);
188
+ return Number.isInteger(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
189
+ }
190
+ function mergeCandidates(existing, candidate) {
191
+ const byNode = new Map(existing.map((item) => [item.nodeId, item]));
192
+ byNode.set(candidate.nodeId, candidate);
193
+ return [...byNode.values()].sort((left, right) => (left.name || left.host).localeCompare(right.name || right.host));
194
+ }
195
+ function cloneJob(job) {
196
+ return {
197
+ ...job,
198
+ candidates: job.candidates.map((candidate) => ({ ...candidate })),
199
+ warnings: [...job.warnings],
200
+ log: [...job.log],
201
+ options: {
202
+ ...job.options,
203
+ targets: [...job.options.targets],
204
+ },
205
+ };
206
+ }
@@ -0,0 +1,223 @@
1
+ import dns from "node:dns/promises";
2
+ import net from "node:net";
3
+ import os from "node:os";
4
+ import { checkPeerIdentityEndpoint } from "./peer-client.js";
5
+ export async function discoverLanPeers(config, options = {}) {
6
+ const warnings = [];
7
+ const targets = await buildDiscoveryTargets(config, options.maxHosts ?? 512, warnings, options.targets ?? []);
8
+ const candidates = [];
9
+ const concurrency = Math.max(1, Math.min(options.concurrency ?? 32, 128));
10
+ let index = 0;
11
+ let scanned = 0;
12
+ async function worker() {
13
+ while (index < targets.length) {
14
+ if (options.signal?.aborted) {
15
+ return;
16
+ }
17
+ const target = targets[index++];
18
+ const startedAt = Date.now();
19
+ const probe = await checkPeerIdentityEndpoint(target.url, { timeoutMs: options.timeoutMs ?? config.peerDiscoveryTimeoutMs });
20
+ scanned += 1;
21
+ if (!probe.ok || !probe.identity) {
22
+ options.onProgress?.({ scanned, total: targets.length, target: target.url });
23
+ continue;
24
+ }
25
+ const candidate = {
26
+ url: target.url,
27
+ host: target.host,
28
+ port: target.port,
29
+ scheme: target.scheme,
30
+ nodeId: probe.identity.nodeId,
31
+ name: probe.identity.name,
32
+ fingerprint: probe.identity.fingerprint,
33
+ tlsFingerprint: probe.tlsFingerprint,
34
+ latencyMs: probe.latencyMs ?? Date.now() - startedAt,
35
+ };
36
+ candidates.push(candidate);
37
+ options.onProgress?.({ scanned, total: targets.length, candidate, target: target.url });
38
+ }
39
+ }
40
+ await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, () => worker()));
41
+ return {
42
+ scanned,
43
+ candidates: dedupeCandidates(candidates),
44
+ warnings: options.signal?.aborted ? [...warnings, "Discovery was cancelled."] : warnings,
45
+ };
46
+ }
47
+ export async function countDiscoveryTargets(config, options = {}) {
48
+ return (await buildDiscoveryTargets(config, options.maxHosts ?? 512, [], options.targets ?? [])).length;
49
+ }
50
+ async function buildDiscoveryTargets(config, maxHosts, warnings, requestedTargets) {
51
+ const schemes = config.peerTlsEnabled ? ["https"] : ["http", "https"];
52
+ const explicitTargets = await customDiscoveryTargets(requestedTargets, config.peerPort, schemes, maxHosts, warnings);
53
+ if (explicitTargets.length > 0) {
54
+ return dedupeTargets(explicitTargets);
55
+ }
56
+ const targets = [];
57
+ const hosts = localSubnetHosts(maxHosts, warnings);
58
+ const mdnsHosts = await mdnsCandidateHosts(warnings);
59
+ if (hosts.length === 0 && mdnsHosts.length === 0) {
60
+ warnings.push("No private IPv4 LAN interface was found for peer discovery.");
61
+ }
62
+ for (const host of [...hosts, ...mdnsHosts]) {
63
+ for (const scheme of schemes) {
64
+ targets.push({ host, scheme, port: config.peerPort, url: formatDiscoveryUrl(scheme, host, config.peerPort) });
65
+ }
66
+ }
67
+ return dedupeTargets(targets);
68
+ }
69
+ async function customDiscoveryTargets(requested, port, schemes, maxHosts, warnings) {
70
+ const targets = [];
71
+ for (const raw of requested.flatMap((value) => value.split(/[\n, ]/)).map((value) => value.trim()).filter(Boolean)) {
72
+ if (/^https?:\/\//i.test(raw)) {
73
+ try {
74
+ const url = new URL(raw);
75
+ const scheme = url.protocol === "http:" ? "http" : "https";
76
+ const targetPort = Number(url.port || port);
77
+ targets.push({ host: url.hostname, scheme, port: targetPort, url: formatDiscoveryUrl(scheme, url.hostname, targetPort) });
78
+ }
79
+ catch {
80
+ warnings.push(`Ignored invalid discovery URL: ${raw}`);
81
+ }
82
+ continue;
83
+ }
84
+ for (const host of expandHostPattern(raw, maxHosts, warnings)) {
85
+ for (const scheme of schemes) {
86
+ targets.push({ host, scheme, port, url: formatDiscoveryUrl(scheme, host, port) });
87
+ }
88
+ }
89
+ }
90
+ return targets;
91
+ }
92
+ function expandHostPattern(raw, maxHosts, warnings) {
93
+ if (raw.includes("/")) {
94
+ return expandIpv4Cidr(raw, maxHosts, warnings);
95
+ }
96
+ const range = raw.match(/^(\d+\.\d+\.\d+\.)(\d+)-(\d+)$/);
97
+ if (range) {
98
+ const start = Number(range[2]);
99
+ const end = Number(range[3]);
100
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > 255 || start > end) {
101
+ warnings.push(`Ignored invalid IPv4 range: ${raw}`);
102
+ return [];
103
+ }
104
+ return Array.from({ length: Math.min(maxHosts, end - start + 1) }, (_, index) => `${range[1]}${start + index}`);
105
+ }
106
+ if (net.isIP(raw) || /^[a-z0-9_.-]+$/i.test(raw)) {
107
+ return [raw];
108
+ }
109
+ warnings.push(`Ignored invalid discovery target: ${raw}`);
110
+ return [];
111
+ }
112
+ function expandIpv4Cidr(raw, maxHosts, warnings) {
113
+ const [address, prefixText] = raw.split("/");
114
+ const prefix = Number(prefixText);
115
+ if (net.isIP(address) !== 4 || !Number.isInteger(prefix) || prefix < 16 || prefix > 32) {
116
+ warnings.push(`Ignored unsupported discovery CIDR: ${raw}. Use IPv4 /16 through /32.`);
117
+ return [];
118
+ }
119
+ const base = ipv4ToNumber(address);
120
+ const hostBits = 32 - prefix;
121
+ const mask = hostBits === 32 ? 0 : (0xffffffff << hostBits) >>> 0;
122
+ const network = base & mask;
123
+ const total = prefix === 32 ? 1 : Math.max(0, (2 ** hostBits) - 2);
124
+ const count = Math.min(total, maxHosts);
125
+ if (total > maxHosts) {
126
+ warnings.push(`CIDR ${raw} was limited to ${maxHosts} host candidates.`);
127
+ }
128
+ return Array.from({ length: count }, (_, index) => numberToIpv4(network + (prefix === 32 ? index : index + 1)));
129
+ }
130
+ async function mdnsCandidateHosts(warnings) {
131
+ const names = [`${os.hostname()}.local`, "nordrelay.local"];
132
+ const found = [];
133
+ for (const name of names) {
134
+ try {
135
+ await withTimeout(dns.lookup(name), 250);
136
+ found.push(name);
137
+ }
138
+ catch {
139
+ // mDNS support depends on the host resolver; absence is normal.
140
+ }
141
+ }
142
+ return found;
143
+ }
144
+ async function withTimeout(promise, timeoutMs) {
145
+ let timeout;
146
+ try {
147
+ return await Promise.race([
148
+ promise,
149
+ new Promise((_, reject) => {
150
+ timeout = setTimeout(() => reject(new Error("mDNS lookup timed out.")), timeoutMs);
151
+ timeout.unref?.();
152
+ }),
153
+ ]);
154
+ }
155
+ finally {
156
+ if (timeout)
157
+ clearTimeout(timeout);
158
+ }
159
+ }
160
+ function formatDiscoveryUrl(scheme, host, port) {
161
+ const displayHost = net.isIP(host) === 6 && !host.startsWith("[") ? `[${host}]` : host;
162
+ return `${scheme}://${displayHost}:${port}`;
163
+ }
164
+ function dedupeTargets(targets) {
165
+ const seen = new Set();
166
+ return targets.filter((target) => {
167
+ const key = target.url.toLowerCase();
168
+ if (seen.has(key))
169
+ return false;
170
+ seen.add(key);
171
+ return true;
172
+ });
173
+ }
174
+ function localSubnetHosts(maxHosts, warnings) {
175
+ const interfaces = os.networkInterfaces();
176
+ const hosts = new Set();
177
+ for (const items of Object.values(interfaces)) {
178
+ for (const item of items ?? []) {
179
+ if (item.family !== "IPv4" || item.internal || !isPrivateIPv4(item.address)) {
180
+ continue;
181
+ }
182
+ const parts = item.address.split(".").map(Number);
183
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) {
184
+ continue;
185
+ }
186
+ const prefix = parts.slice(0, 3).join(".");
187
+ for (let last = 1; last <= 254; last += 1) {
188
+ const host = `${prefix}.${last}`;
189
+ if (host !== item.address) {
190
+ hosts.add(host);
191
+ }
192
+ if (hosts.size >= maxHosts) {
193
+ warnings.push(`LAN discovery was limited to ${maxHosts} host candidates.`);
194
+ return [...hosts];
195
+ }
196
+ }
197
+ }
198
+ }
199
+ return [...hosts];
200
+ }
201
+ function isPrivateIPv4(address) {
202
+ const [a, b] = address.split(".").map(Number);
203
+ return a === 10 ||
204
+ (a === 172 && b >= 16 && b <= 31) ||
205
+ (a === 192 && b === 168) ||
206
+ (a === 169 && b === 254);
207
+ }
208
+ function ipv4ToNumber(address) {
209
+ return address.split(".").map(Number).reduce((sum, part) => ((sum << 8) + part) >>> 0, 0);
210
+ }
211
+ function numberToIpv4(value) {
212
+ return [24, 16, 8, 0].map((shift) => (value >>> shift) & 255).join(".");
213
+ }
214
+ function dedupeCandidates(candidates) {
215
+ const byNode = new Map();
216
+ for (const candidate of candidates) {
217
+ const existing = byNode.get(candidate.nodeId);
218
+ if (!existing || (candidate.latencyMs ?? Number.MAX_SAFE_INTEGER) < (existing.latencyMs ?? Number.MAX_SAFE_INTEGER)) {
219
+ byNode.set(candidate.nodeId, candidate);
220
+ }
221
+ }
222
+ return [...byNode.values()].sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
223
+ }
@@ -0,0 +1,49 @@
1
+ import { RemoteRelayClient } from "./peer-client.js";
2
+ import { PeerStore } from "./peer-store.js";
3
+ export function startPeerHealthMonitor(options) {
4
+ const store = new PeerStore(options.home);
5
+ const client = new RemoteRelayClient(store);
6
+ let running = false;
7
+ let timer;
8
+ async function checkNow() {
9
+ if (running) {
10
+ return;
11
+ }
12
+ running = true;
13
+ try {
14
+ const peers = store.list().filter((peer) => peer.enabled && peer.url);
15
+ await Promise.all(peers.map(async (peer) => {
16
+ try {
17
+ const startedAt = Date.now();
18
+ const result = await client.rpc(peer.id, "peer.ping");
19
+ const record = result && typeof result === "object" ? result : {};
20
+ store.markSeen(peer.id, {
21
+ latencyMs: Date.now() - startedAt,
22
+ remoteVersion: typeof record.version === "string" ? record.version : undefined,
23
+ remoteStatus: typeof record.status === "string" ? record.status : "online",
24
+ });
25
+ }
26
+ catch (error) {
27
+ store.markError(peer.id, error instanceof Error ? error.message : String(error));
28
+ }
29
+ }));
30
+ }
31
+ finally {
32
+ running = false;
33
+ }
34
+ }
35
+ if (options.config.peerHealthCheckMs > 0) {
36
+ timer = setInterval(() => void checkNow().catch(() => { }), options.config.peerHealthCheckMs);
37
+ timer.unref?.();
38
+ setTimeout(() => void checkNow().catch(() => { }), 2_000).unref?.();
39
+ }
40
+ return {
41
+ checkNow,
42
+ close() {
43
+ if (timer) {
44
+ clearInterval(timer);
45
+ timer = undefined;
46
+ }
47
+ },
48
+ };
49
+ }
@@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import selfsigned from "selfsigned";
6
- import { readJsonFileWithBackup, writeJsonFileAtomic, writeTextFileAtomic } from "./persistence.js";
6
+ import { readJsonFileWithBackup, writeJsonFileAtomic, writeTextFileAtomic } from "../state/persistence.js";
7
7
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
8
8
  export function loadOrCreatePeerIdentity(home = process.env.NORDRELAY_HOME || DEFAULT_HOME, name) {
9
9
  const filePath = path.join(home, "identity.json");
@@ -48,6 +48,55 @@ export function loadOrCreatePeerIdentity(home = process.env.NORDRELAY_HOME || DE
48
48
  privateKey: identity.privateKey,
49
49
  };
50
50
  }
51
+ export function exportPeerIdentityBackup(home = process.env.NORDRELAY_HOME || DEFAULT_HOME, name) {
52
+ const identity = loadOrCreatePeerIdentity(home, name);
53
+ const tls = existsSync(path.join(home, "tls", "peer.crt"))
54
+ ? ensurePeerTlsFiles(home, identity.public)
55
+ : undefined;
56
+ return {
57
+ version: 1,
58
+ exportedAt: new Date().toISOString(),
59
+ identity: identity.public,
60
+ privateKey: identity.privateKey,
61
+ tlsFingerprint: tls?.fingerprint,
62
+ };
63
+ }
64
+ export function restorePeerIdentityBackup(backup, home = process.env.NORDRELAY_HOME || DEFAULT_HOME) {
65
+ if (!backup || backup.version !== 1 || !backup.identity?.publicKey || !backup.privateKey) {
66
+ throw new Error("Invalid peer identity backup.");
67
+ }
68
+ const fingerprint = fingerprintForPublicKey(backup.identity.publicKey);
69
+ if (fingerprint !== backup.identity.fingerprint) {
70
+ throw new Error("Peer identity backup fingerprint does not match the public key.");
71
+ }
72
+ const probe = `nordrelay-identity-restore:${Date.now()}`;
73
+ const signature = signPeerPayload(backup.privateKey, probe);
74
+ if (!verifyPeerPayload(backup.identity.publicKey, probe, signature)) {
75
+ throw new Error("Peer identity backup private key does not match the public key.");
76
+ }
77
+ const payload = {
78
+ nodeId: backup.identity.nodeId,
79
+ name: backup.identity.name || defaultNodeName(),
80
+ publicKey: backup.identity.publicKey,
81
+ privateKey: backup.privateKey,
82
+ fingerprint,
83
+ createdAt: backup.identity.createdAt || new Date().toISOString(),
84
+ };
85
+ const filePath = path.join(home, "identity.json");
86
+ mkdirSync(path.dirname(filePath), { recursive: true });
87
+ writeJsonFileAtomic(filePath, payload);
88
+ chmodSync(filePath, 0o600);
89
+ return {
90
+ public: {
91
+ nodeId: payload.nodeId,
92
+ name: payload.name,
93
+ publicKey: payload.publicKey,
94
+ fingerprint: payload.fingerprint,
95
+ createdAt: payload.createdAt,
96
+ },
97
+ privateKey: payload.privateKey,
98
+ };
99
+ }
51
100
  export function ensurePeerTlsFiles(home = process.env.NORDRELAY_HOME || DEFAULT_HOME, identity) {
52
101
  const certDir = path.join(home, "tls");
53
102
  const certPath = path.join(certDir, "peer.crt");
@@ -1,11 +1,12 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { enabledAgents } from "./agent-factory.js";
3
- import { listAgentAdapterDescriptors } from "./agent-adapter.js";
4
- import { isAgentId } from "./agent.js";
5
- import { permissionForWebRequest } from "./access-control.js";
6
- import { listChannelDescriptors } from "./channel-adapter.js";
7
- import { friendlyErrorText } from "./error-messages.js";
8
- import { getPackageVersion } from "./operations.js";
2
+ import { enabledAgents } from "../agents/shared/agent-factory.js";
3
+ import { listAgentAdapterDescriptors } from "../agents/shared/agent-adapter.js";
4
+ import { buildAdapterConformanceMatrix } from "../agents/shared/adapter-conformance.js";
5
+ import { isAgentId } from "../agents/shared/agent.js";
6
+ import { permissionForWebRequest } from "../access/access-control.js";
7
+ import { listChannelDescriptors } from "../channels/shared/channel-adapter.js";
8
+ import { friendlyErrorText } from "../core/error-messages.js";
9
+ import { getPackageVersion } from "../support/operations.js";
9
10
  import { checkPeerEndpoint } from "./peer-client.js";
10
11
  export class PeerRuntimeService {
11
12
  config;
@@ -147,6 +148,12 @@ export class PeerRuntimeService {
147
148
  if (method === "GET" && path === "/api/adapters/health") {
148
149
  return { adapters: (await runtime.adapterHealth()).filter((adapter) => this.canUseAgent(peer, adapter.id)) };
149
150
  }
151
+ if (method === "GET" && path === "/api/adapters/conformance") {
152
+ return buildAdapterConformanceMatrix({
153
+ agents: listAgentAdapterDescriptors().filter((adapter) => this.canUseAgent(peer, adapter.id)),
154
+ channels: listChannelDescriptors(),
155
+ });
156
+ }
150
157
  if (method === "GET" && path === "/api/diagnostics")
151
158
  return this.scopedDiagnostics(peer, await runtime.diagnostics());
152
159
  if (method === "GET" && path === "/api/diagnostics/bundle") {
@@ -163,6 +170,13 @@ export class PeerRuntimeService {
163
170
  this.assertAgentScope(peer, agentId);
164
171
  return this.scopedControlOptions(peer, await runtime.controlOptions(agentId));
165
172
  }
173
+ if (method === "GET" && path === "/api/locks")
174
+ return { locks: runtime.locks() };
175
+ if (method === "POST" && path === "/api/locks") {
176
+ return { lock: runtime.lockWebSession(stringValue(body.ownerName) || `Peer ${peer.name}`, remoteActor), locks: runtime.locks() };
177
+ }
178
+ if (method === "DELETE" && path === "/api/locks")
179
+ return runtime.unlockWebSession(remoteActor);
166
180
  if (method === "GET" && path === "/api/auth/status") {
167
181
  const agentId = parseAgentId(query.agent);
168
182
  this.assertAgentScope(peer, agentId);
@@ -281,6 +295,14 @@ export class PeerRuntimeService {
281
295
  await this.assertCurrentSessionScope(peer, runtime);
282
296
  return { messages: await runtime.chatHistory(numberValue(query.limit, 200)) };
283
297
  }
298
+ if (method === "GET" && path === "/api/chat/mirror") {
299
+ await this.assertCurrentSessionScope(peer, runtime);
300
+ return runtime.webMirrorPreference("");
301
+ }
302
+ if (method === "POST" && path === "/api/chat/mirror") {
303
+ await this.assertCurrentSessionScope(peer, runtime);
304
+ return runtime.webMirrorPreference(stringValue(body.argument) || stringValue(body.mode) || "", remoteActor);
305
+ }
284
306
  if (method === "DELETE" && path === "/api/chat/history") {
285
307
  await this.assertCurrentSessionScope(peer, runtime);
286
308
  return runtime.clearChatHistory(remoteActor);