@nordbyte/nordrelay 0.5.2 → 0.7.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.
Files changed (71) hide show
  1. package/.env.example +80 -11
  2. package/README.md +154 -22
  3. package/dist/access-control.js +7 -1
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot-rendering.js +10 -7
  8. package/dist/bot.js +535 -11
  9. package/dist/channel-actions.js +7 -2
  10. package/dist/channel-adapter.js +40 -7
  11. package/dist/channel-command-catalog.js +88 -0
  12. package/dist/channel-command-service.js +369 -0
  13. package/dist/channel-mirror-registry.js +77 -0
  14. package/dist/channel-peer-prompt.js +95 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-service.js +237 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +93 -13
  19. package/dist/config.js +103 -8
  20. package/dist/context-key.js +87 -5
  21. package/dist/discord-artifacts.js +165 -0
  22. package/dist/discord-bot.js +2073 -0
  23. package/dist/discord-channel-runtime.js +133 -0
  24. package/dist/discord-command-surface.js +57 -0
  25. package/dist/discord-rate-limit.js +141 -0
  26. package/dist/index.js +36 -5
  27. package/dist/job-store.js +127 -0
  28. package/dist/metrics.js +87 -0
  29. package/dist/peer-auth.js +85 -0
  30. package/dist/peer-client.js +256 -0
  31. package/dist/peer-context.js +21 -0
  32. package/dist/peer-identity.js +127 -0
  33. package/dist/peer-runtime-service.js +636 -0
  34. package/dist/peer-server.js +220 -0
  35. package/dist/peer-store.js +294 -0
  36. package/dist/peer-types.js +52 -0
  37. package/dist/relay-external-activity-monitor.js +47 -6
  38. package/dist/relay-runtime-helpers.js +208 -0
  39. package/dist/relay-runtime.js +897 -394
  40. package/dist/remote-prompt.js +98 -0
  41. package/dist/runtime-cache.js +57 -0
  42. package/dist/session-locks.js +10 -7
  43. package/dist/support-bundle.js +1 -0
  44. package/dist/telegram-access-commands.js +15 -2
  45. package/dist/telegram-access-middleware.js +16 -3
  46. package/dist/telegram-agent-commands.js +25 -0
  47. package/dist/telegram-artifact-commands.js +46 -0
  48. package/dist/telegram-command-menu.js +3 -53
  49. package/dist/telegram-diagnostics-command.js +5 -50
  50. package/dist/telegram-general-commands.js +16 -6
  51. package/dist/telegram-operational-commands.js +14 -6
  52. package/dist/telegram-preference-commands.js +23 -127
  53. package/dist/telegram-queue-commands.js +74 -4
  54. package/dist/telegram-support-command.js +7 -0
  55. package/dist/telegram-update-commands.js +27 -0
  56. package/dist/user-management.js +208 -0
  57. package/dist/web-api-contract.js +17 -0
  58. package/dist/web-dashboard-access-routes.js +74 -1
  59. package/dist/web-dashboard-artifact-routes.js +3 -3
  60. package/dist/web-dashboard-assets.js +2 -0
  61. package/dist/web-dashboard-pages.js +109 -13
  62. package/dist/web-dashboard-peer-routes.js +204 -0
  63. package/dist/web-dashboard-runtime-routes.js +53 -8
  64. package/dist/web-dashboard-session-routes.js +27 -20
  65. package/dist/web-dashboard-ui.js +2 -0
  66. package/dist/web-dashboard.js +160 -6
  67. package/dist/web-state.js +33 -2
  68. package/dist/webui-assets/dashboard.css +75 -1
  69. package/dist/webui-assets/dashboard.js +779 -55
  70. package/package.json +5 -2
  71. package/plugins/nordrelay/scripts/nordrelay.mjs +578 -19
@@ -0,0 +1,220 @@
1
+ import { createServer as createHttpServer } from "node:http";
2
+ import { createServer as createHttpsServer } from "node:https";
3
+ import { URL } from "node:url";
4
+ import { friendlyErrorText } from "./error-messages.js";
5
+ import { createPairingSignaturePayload, createSharedSecret, ensurePeerTlsFiles, fingerprintForPublicKey, loadOrCreatePeerIdentity, verifyPeerPayload, } from "./peer-identity.js";
6
+ import { header, PeerNonceCache, verifyPeerRequest } from "./peer-auth.js";
7
+ import { peerRuntimeContextKey } from "./peer-context.js";
8
+ import { PeerStore } from "./peer-store.js";
9
+ import { PeerRuntimeService, peerError } from "./peer-runtime-service.js";
10
+ import { PEER_PROTOCOL_VERSION, } from "./peer-types.js";
11
+ import { RelayRuntime } from "./relay-runtime.js";
12
+ export async function startPeerServer(options) {
13
+ const { config, runtime } = options;
14
+ if (!config.peerEnabled) {
15
+ return null;
16
+ }
17
+ const home = options.home ?? process.env.NORDRELAY_HOME;
18
+ const identity = loadOrCreatePeerIdentity(home, config.peerName);
19
+ const store = new PeerStore(home);
20
+ const nonces = new PeerNonceCache();
21
+ const contextRuntimes = new Map();
22
+ const service = new PeerRuntimeService(config, runtime, {
23
+ runtimeForContext: (peer, sourceContextKey) => {
24
+ const contextKey = peerRuntimeContextKey(peer, sourceContextKey);
25
+ let scoped = contextRuntimes.get(contextKey);
26
+ if (!scoped) {
27
+ scoped = new RelayRuntime(config, {
28
+ contextKey,
29
+ registryFileName: "peer-contexts.json",
30
+ registrySqliteKey: "peer-contexts",
31
+ });
32
+ contextRuntimes.set(contextKey, scoped);
33
+ }
34
+ return scoped;
35
+ },
36
+ });
37
+ const useTls = config.peerTlsEnabled;
38
+ const tls = useTls ? ensurePeerTlsFiles(home, identity.public) : null;
39
+ if (!useTls && config.peerRequireTls && !isLoopbackHost(config.peerHost)) {
40
+ throw new Error("Peer server refuses plaintext on non-loopback hosts. Set NORDRELAY_PEER_TLS_ENABLED=true or bind to 127.0.0.1.");
41
+ }
42
+ const server = useTls
43
+ ? createHttpsServer({ cert: tls.cert, key: tls.key }, handleRequest)
44
+ : createHttpServer(handleRequest);
45
+ await new Promise((resolve) => server.listen(config.peerPort, config.peerHost, resolve));
46
+ const scheme = useTls ? "https" : "http";
47
+ const host = config.peerHost === "0.0.0.0" || config.peerHost === "::" ? "127.0.0.1" : config.peerHost;
48
+ const displayHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
49
+ const address = server.address();
50
+ const actualPort = typeof address === "object" && address ? address.port : config.peerPort;
51
+ const url = config.peerPublicUrl || `${scheme}://${displayHost}:${actualPort}`;
52
+ console.log(`Peer server: ${url} (${useTls ? `TLS ${tls.fingerprint}` : "plaintext loopback"})`);
53
+ return {
54
+ url,
55
+ tlsFingerprint: tls?.fingerprint,
56
+ close: async () => {
57
+ await closeServer(server);
58
+ for (const scoped of contextRuntimes.values()) {
59
+ scoped.dispose();
60
+ }
61
+ },
62
+ };
63
+ async function handleRequest(req, res) {
64
+ try {
65
+ const url = new URL(req.url ?? "/", "http://localhost");
66
+ if (req.method === "GET" && url.pathname === "/peer/healthz") {
67
+ sendJson(res, 200, { ok: true, protocolVersion: PEER_PROTOCOL_VERSION });
68
+ return;
69
+ }
70
+ if (req.method === "GET" && url.pathname === "/peer/identity") {
71
+ sendJson(res, 200, {
72
+ protocolVersion: PEER_PROTOCOL_VERSION,
73
+ identity: identity.public,
74
+ tlsFingerprint: tls?.fingerprint,
75
+ });
76
+ return;
77
+ }
78
+ if (req.method === "POST" && url.pathname === "/peer/pair") {
79
+ const bodyText = await readBody(req, 128 * 1024);
80
+ const body = parseJson(bodyText);
81
+ const response = handlePair(body);
82
+ sendJson(res, 201, response);
83
+ return;
84
+ }
85
+ if (req.method === "POST" && url.pathname === "/peer/rpc") {
86
+ const bodyText = await readBody(req, 64 * 1024 * 1024);
87
+ const peer = authenticate(req, "POST", "/peer/rpc", bodyText);
88
+ const body = parseJson(bodyText);
89
+ const data = await service.handle(peer, body);
90
+ const result = { ok: true, data };
91
+ sendJson(res, 200, result);
92
+ return;
93
+ }
94
+ if (req.method === "GET" && url.pathname === "/peer/events") {
95
+ const peer = authenticate(req, "GET", `${url.pathname}${url.search}`, "");
96
+ res.writeHead(200, {
97
+ "content-type": "text/event-stream; charset=utf-8",
98
+ "cache-control": "no-cache, no-transform",
99
+ connection: "keep-alive",
100
+ });
101
+ const sourceContextKey = url.searchParams.get("contextKey") || undefined;
102
+ const unsubscribe = service.subscribe(peer, sourceContextKey, (event) => {
103
+ if (res.destroyed || res.writableEnded)
104
+ return;
105
+ res.write(`event: ${event.type}\n`);
106
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
107
+ });
108
+ const heartbeat = setInterval(() => {
109
+ if (!res.destroyed && !res.writableEnded)
110
+ res.write(": heartbeat\n\n");
111
+ }, 25_000);
112
+ heartbeat.unref?.();
113
+ req.on("close", () => {
114
+ clearInterval(heartbeat);
115
+ unsubscribe();
116
+ });
117
+ return;
118
+ }
119
+ sendJson(res, 404, { error: "not found" });
120
+ }
121
+ catch (error) {
122
+ const status = isAuthError(error) ? 403 : 500;
123
+ sendJson(res, status, { error: friendlyErrorText(error) });
124
+ }
125
+ }
126
+ function handlePair(body) {
127
+ if (!body?.identity?.nodeId || !body.identity.publicKey || !body.code || !body.signature || !body.timestamp) {
128
+ throw new Error("Invalid peer pairing request.");
129
+ }
130
+ if (fingerprintForPublicKey(body.identity.publicKey) !== body.identity.fingerprint) {
131
+ throw new Error("Pairing identity fingerprint mismatch.");
132
+ }
133
+ if (body.identity.nodeId === identity.public.nodeId) {
134
+ throw new Error("Refusing to pair this NordRelay instance with itself.");
135
+ }
136
+ if (Math.abs(Date.now() - Date.parse(body.timestamp)) > 5 * 60 * 1000) {
137
+ throw new Error("Pairing request timestamp is outside the allowed clock skew.");
138
+ }
139
+ const signaturePayload = createPairingSignaturePayload(body.identity.nodeId, body.timestamp, body.code);
140
+ if (!verifyPeerPayload(body.identity.publicKey, signaturePayload, body.signature)) {
141
+ throw new Error("Invalid peer pairing signature.");
142
+ }
143
+ const invitation = store.consumeInvitation(body.code, body.identity.nodeId);
144
+ const secret = createSharedSecret();
145
+ const peer = store.upsertPeer({
146
+ name: body.name?.trim() || body.identity.name || invitation.name,
147
+ url: body.publicUrl,
148
+ nodeId: body.identity.nodeId,
149
+ publicKey: body.identity.publicKey,
150
+ fingerprint: body.identity.fingerprint,
151
+ secret,
152
+ enabled: true,
153
+ direction: body.publicUrl ? "bidirectional" : "inbound",
154
+ scopes: invitation.scopes,
155
+ allowedAgents: invitation.allowedAgents,
156
+ allowedWorkspaceRoots: invitation.allowedWorkspaceRoots,
157
+ workspaceAliases: invitation.workspaceAliases,
158
+ });
159
+ return {
160
+ protocolVersion: PEER_PROTOCOL_VERSION,
161
+ identity: identity.public,
162
+ peerId: peer.id,
163
+ secret,
164
+ scopes: peer.scopes,
165
+ allowedAgents: peer.allowedAgents,
166
+ allowedWorkspaceRoots: peer.allowedWorkspaceRoots,
167
+ workspaceAliases: peer.workspaceAliases,
168
+ };
169
+ }
170
+ function authenticate(req, method, pathname, body) {
171
+ const peerId = header(req, "x-nordrelay-peer-id");
172
+ const peer = peerId ? store.get(peerId) : null;
173
+ if (!peer) {
174
+ throw new Error("Unknown peer.");
175
+ }
176
+ if (!peer.enabled) {
177
+ throw new Error("Peer is disabled.");
178
+ }
179
+ verifyPeerRequest({ req, peer, method, pathname, body, nonces });
180
+ store.markSeen(peer.id);
181
+ return peer;
182
+ }
183
+ }
184
+ async function readBody(req, maxBytes) {
185
+ const chunks = [];
186
+ let size = 0;
187
+ for await (const chunk of req) {
188
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
189
+ size += buffer.length;
190
+ if (size > maxBytes) {
191
+ throw new Error("Peer request body is too large.");
192
+ }
193
+ chunks.push(buffer);
194
+ }
195
+ return Buffer.concat(chunks).toString("utf8");
196
+ }
197
+ function parseJson(text) {
198
+ try {
199
+ return JSON.parse(text || "{}");
200
+ }
201
+ catch {
202
+ throw new Error("Invalid JSON body.");
203
+ }
204
+ }
205
+ function sendJson(res, status, value) {
206
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
207
+ res.end(`${JSON.stringify(value)}\n`);
208
+ }
209
+ function isLoopbackHost(host) {
210
+ return host === "127.0.0.1" || host === "::1" || host === "localhost";
211
+ }
212
+ function isAuthError(error) {
213
+ const message = peerError(error).toLowerCase();
214
+ return /peer|signature|timestamp|replay|permission|denied|auth|disabled/.test(message);
215
+ }
216
+ function closeServer(server) {
217
+ return new Promise((resolve, reject) => {
218
+ server.close((error) => error ? reject(error) : resolve());
219
+ });
220
+ }
@@ -0,0 +1,294 @@
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { mkdirSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { ALL_PERMISSIONS } from "./access-control.js";
6
+ import { AGENT_IDS, isAgentId } from "./agent.js";
7
+ import { readJsonFileWithBackup, writeJsonFileAtomic } from "./persistence.js";
8
+ import { DEFAULT_PEER_SCOPES, publicInvitation, publicPeer, } from "./peer-types.js";
9
+ const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
10
+ const INVITE_CODE_BYTES = 18;
11
+ const MAX_INVITATION_TTL_MS = 24 * 60 * 60 * 1000;
12
+ export class PeerStore {
13
+ filePath;
14
+ constructor(home = process.env.NORDRELAY_HOME || DEFAULT_HOME) {
15
+ this.filePath = path.join(home, "peers.json");
16
+ }
17
+ snapshot(identity, options) {
18
+ const payload = this.readPayload();
19
+ return {
20
+ identity,
21
+ enabled: options.enabled,
22
+ listenUrl: options.listenUrl,
23
+ requireTls: options.requireTls,
24
+ peers: payload.peers.map(publicPeer),
25
+ invitations: payload.invitations.map(publicInvitation),
26
+ };
27
+ }
28
+ list() {
29
+ return this.readPayload().peers;
30
+ }
31
+ listPublic() {
32
+ return this.list().map(publicPeer);
33
+ }
34
+ get(id) {
35
+ return this.readPayload().peers.find((peer) => peer.id === id || peer.nodeId === id) ?? null;
36
+ }
37
+ createInvitation(options = {}) {
38
+ const code = randomBytes(INVITE_CODE_BYTES).toString("base64url");
39
+ const now = new Date();
40
+ const requestedTtl = options.expiresInMs ?? 10 * 60 * 1000;
41
+ const ttl = Math.max(1_000, Math.min(requestedTtl, MAX_INVITATION_TTL_MS));
42
+ const expiresAt = new Date(now.getTime() + ttl);
43
+ const invitation = {
44
+ id: randomUUID().replace(/-/g, "").slice(0, 12),
45
+ name: options.name?.trim() || "NordRelay peer",
46
+ codeHash: hashSecret(code),
47
+ createdAt: now.toISOString(),
48
+ expiresAt: expiresAt.toISOString(),
49
+ scopes: normalizeScopes(options.scopes ?? DEFAULT_PEER_SCOPES),
50
+ allowedAgents: normalizeAgents(options.allowedAgents ?? [...AGENT_IDS]),
51
+ allowedWorkspaceRoots: normalizeWorkspaceRoots(options.allowedWorkspaceRoots ?? []),
52
+ workspaceAliases: normalizeWorkspaceAliases(options.workspaceAliases ?? {}),
53
+ };
54
+ this.mutatePayload((payload) => {
55
+ payload.invitations = payload.invitations.filter((item) => !item.usedAt && Date.parse(item.expiresAt) > Date.now());
56
+ payload.invitations.push(invitation);
57
+ });
58
+ return { invitation: publicInvitation(invitation), code };
59
+ }
60
+ consumeInvitation(code, usedByNodeId) {
61
+ const trimmed = code.trim();
62
+ if (!trimmed) {
63
+ throw new Error("Pairing code is required.");
64
+ }
65
+ let consumed = null;
66
+ this.mutatePayload((payload) => {
67
+ const invitation = payload.invitations.find((item) => !item.usedAt && verifySecret(trimmed, item.codeHash));
68
+ if (!invitation) {
69
+ throw new Error("Invalid or already used pairing code.");
70
+ }
71
+ if (Date.parse(invitation.expiresAt) <= Date.now()) {
72
+ throw new Error("Pairing code has expired.");
73
+ }
74
+ invitation.usedAt = new Date().toISOString();
75
+ invitation.usedByNodeId = usedByNodeId;
76
+ consumed = { ...invitation, scopes: [...invitation.scopes], allowedAgents: [...invitation.allowedAgents], allowedWorkspaceRoots: [...invitation.allowedWorkspaceRoots] };
77
+ });
78
+ if (!consumed) {
79
+ throw new Error("Pairing code could not be consumed.");
80
+ }
81
+ return consumed;
82
+ }
83
+ upsertPeer(input) {
84
+ const now = new Date().toISOString();
85
+ let next = null;
86
+ this.mutatePayload((payload) => {
87
+ const existing = payload.peers.find((peer) => peer.nodeId === input.nodeId || (input.id && peer.id === input.id));
88
+ if (existing) {
89
+ existing.name = input.name.trim() || existing.name;
90
+ existing.url = input.url ?? existing.url;
91
+ existing.publicKey = input.publicKey;
92
+ existing.fingerprint = input.fingerprint;
93
+ existing.tlsFingerprint = input.tlsFingerprint ?? existing.tlsFingerprint;
94
+ existing.secret = input.secret;
95
+ existing.enabled = input.enabled ?? existing.enabled;
96
+ existing.direction = mergeDirection(existing.direction, input.direction ?? existing.direction);
97
+ existing.scopes = normalizeScopes(input.scopes ?? existing.scopes);
98
+ existing.allowedAgents = normalizeAgents(input.allowedAgents ?? existing.allowedAgents);
99
+ existing.allowedWorkspaceRoots = normalizeWorkspaceRoots(input.allowedWorkspaceRoots ?? existing.allowedWorkspaceRoots);
100
+ existing.workspaceAliases = normalizeWorkspaceAliases(input.workspaceAliases ?? existing.workspaceAliases ?? {});
101
+ existing.updatedAt = now;
102
+ delete existing.lastError;
103
+ next = clonePeer(existing);
104
+ return;
105
+ }
106
+ const record = {
107
+ id: input.id ?? randomUUID().replace(/-/g, "").slice(0, 12),
108
+ name: input.name.trim() || "NordRelay peer",
109
+ url: input.url,
110
+ nodeId: input.nodeId,
111
+ publicKey: input.publicKey,
112
+ fingerprint: input.fingerprint,
113
+ tlsFingerprint: input.tlsFingerprint,
114
+ secret: input.secret,
115
+ enabled: input.enabled ?? true,
116
+ direction: input.direction ?? "outbound",
117
+ scopes: normalizeScopes(input.scopes ?? DEFAULT_PEER_SCOPES),
118
+ allowedAgents: normalizeAgents(input.allowedAgents ?? [...AGENT_IDS]),
119
+ allowedWorkspaceRoots: normalizeWorkspaceRoots(input.allowedWorkspaceRoots ?? []),
120
+ workspaceAliases: normalizeWorkspaceAliases(input.workspaceAliases ?? {}),
121
+ createdAt: now,
122
+ updatedAt: now,
123
+ };
124
+ payload.peers.push(record);
125
+ next = clonePeer(record);
126
+ });
127
+ if (!next) {
128
+ throw new Error("Peer could not be saved.");
129
+ }
130
+ return next;
131
+ }
132
+ updatePeer(id, patch) {
133
+ let next = null;
134
+ this.mutatePayload((payload) => {
135
+ const peer = payload.peers.find((candidate) => candidate.id === id || candidate.nodeId === id);
136
+ if (!peer) {
137
+ throw new Error("Peer not found.");
138
+ }
139
+ if (patch.name !== undefined)
140
+ peer.name = patch.name.trim() || peer.name;
141
+ if (patch.url !== undefined)
142
+ peer.url = patch.url.trim() || undefined;
143
+ if (patch.enabled !== undefined)
144
+ peer.enabled = patch.enabled;
145
+ if (patch.scopes !== undefined)
146
+ peer.scopes = normalizeScopes(patch.scopes);
147
+ if (patch.allowedAgents !== undefined)
148
+ peer.allowedAgents = normalizeAgents(patch.allowedAgents);
149
+ if (patch.allowedWorkspaceRoots !== undefined)
150
+ peer.allowedWorkspaceRoots = normalizeWorkspaceRoots(patch.allowedWorkspaceRoots);
151
+ if (patch.workspaceAliases !== undefined)
152
+ peer.workspaceAliases = normalizeWorkspaceAliases(patch.workspaceAliases);
153
+ peer.updatedAt = new Date().toISOString();
154
+ next = clonePeer(peer);
155
+ });
156
+ if (!next) {
157
+ throw new Error("Peer not found.");
158
+ }
159
+ return next;
160
+ }
161
+ markSeen(id, patch = {}) {
162
+ this.patchPeer(id, {
163
+ lastSeenAt: new Date().toISOString(),
164
+ lastCheckedAt: new Date().toISOString(),
165
+ lastLatencyMs: patch.latencyMs,
166
+ remoteVersion: patch.remoteVersion,
167
+ remoteStatus: patch.remoteStatus ?? "online",
168
+ lastError: undefined,
169
+ });
170
+ }
171
+ markError(id, error) {
172
+ this.patchPeer(id, { lastError: error, remoteStatus: "offline", lastCheckedAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
173
+ }
174
+ revokePeer(id) {
175
+ let removed = false;
176
+ this.mutatePayload((payload) => {
177
+ const before = payload.peers.length;
178
+ payload.peers = payload.peers.filter((peer) => peer.id !== id && peer.nodeId !== id);
179
+ removed = payload.peers.length !== before;
180
+ });
181
+ return removed;
182
+ }
183
+ patchPeer(id, patch) {
184
+ this.mutatePayload((payload) => {
185
+ const peer = payload.peers.find((candidate) => candidate.id === id || candidate.nodeId === id);
186
+ if (!peer)
187
+ return;
188
+ Object.assign(peer, patch);
189
+ });
190
+ }
191
+ mutatePayload(mutator) {
192
+ const payload = this.readPayload();
193
+ const result = mutator(payload);
194
+ mkdirSync(path.dirname(this.filePath), { recursive: true });
195
+ writeJsonFileAtomic(this.filePath, payload);
196
+ return result;
197
+ }
198
+ readPayload() {
199
+ const payload = readJsonFileWithBackup(this.filePath).value;
200
+ if (!payload || payload.version !== 1 || !Array.isArray(payload.peers) || !Array.isArray(payload.invitations)) {
201
+ return { version: 1, peers: [], invitations: [] };
202
+ }
203
+ return {
204
+ version: 1,
205
+ peers: payload.peers.filter(isPeerRecord).map((peer) => ({
206
+ ...peer,
207
+ scopes: normalizeScopes(peer.scopes),
208
+ allowedAgents: normalizeAgents(peer.allowedAgents),
209
+ allowedWorkspaceRoots: normalizeWorkspaceRoots(peer.allowedWorkspaceRoots),
210
+ workspaceAliases: normalizeWorkspaceAliases(peer.workspaceAliases ?? {}),
211
+ })),
212
+ invitations: payload.invitations.filter(isInvitationRecord).map((invitation) => ({
213
+ ...invitation,
214
+ scopes: normalizeScopes(invitation.scopes),
215
+ allowedAgents: normalizeAgents(invitation.allowedAgents),
216
+ allowedWorkspaceRoots: normalizeWorkspaceRoots(invitation.allowedWorkspaceRoots),
217
+ workspaceAliases: normalizeWorkspaceAliases(invitation.workspaceAliases ?? {}),
218
+ })),
219
+ };
220
+ }
221
+ }
222
+ export function hashSecret(value) {
223
+ const salt = randomBytes(16).toString("hex");
224
+ const digest = createHash("sha256").update(`${salt}:${value}`).digest("hex");
225
+ return `${salt}:${digest}`;
226
+ }
227
+ export function verifySecret(value, stored) {
228
+ const [salt, digest] = stored.split(":");
229
+ if (!salt || !digest) {
230
+ return false;
231
+ }
232
+ const next = createHash("sha256").update(`${salt}:${value}`).digest();
233
+ const previous = Buffer.from(digest, "hex");
234
+ return previous.length === next.length && timingSafeEqual(previous, next);
235
+ }
236
+ function normalizeScopes(values) {
237
+ const allowed = new Set(ALL_PERMISSIONS);
238
+ return [...new Set(values.filter((value) => allowed.has(value)))];
239
+ }
240
+ function normalizeAgents(values) {
241
+ return [...new Set(values.filter(isAgentId))];
242
+ }
243
+ function normalizeWorkspaceRoots(values) {
244
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
245
+ }
246
+ function normalizeWorkspaceAliases(value) {
247
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
248
+ return {};
249
+ }
250
+ const aliases = {};
251
+ for (const [rawAlias, rawWorkspace] of Object.entries(value ?? {})) {
252
+ const alias = rawAlias.trim();
253
+ const workspace = String(rawWorkspace ?? "").trim();
254
+ if (!alias || !workspace || /[,\s]/.test(alias))
255
+ continue;
256
+ aliases[alias] = workspace;
257
+ }
258
+ return aliases;
259
+ }
260
+ function clonePeer(peer) {
261
+ return {
262
+ ...peer,
263
+ scopes: [...peer.scopes],
264
+ allowedAgents: [...peer.allowedAgents],
265
+ allowedWorkspaceRoots: [...peer.allowedWorkspaceRoots],
266
+ workspaceAliases: { ...peer.workspaceAliases },
267
+ };
268
+ }
269
+ function mergeDirection(left, right) {
270
+ if (left === right)
271
+ return left;
272
+ return "bidirectional";
273
+ }
274
+ function isPeerRecord(value) {
275
+ if (!value || typeof value !== "object" || Array.isArray(value))
276
+ return false;
277
+ const record = value;
278
+ return typeof record.id === "string" &&
279
+ typeof record.name === "string" &&
280
+ typeof record.nodeId === "string" &&
281
+ typeof record.publicKey === "string" &&
282
+ typeof record.fingerprint === "string" &&
283
+ typeof record.secret === "string" &&
284
+ typeof record.enabled === "boolean";
285
+ }
286
+ function isInvitationRecord(value) {
287
+ if (!value || typeof value !== "object" || Array.isArray(value))
288
+ return false;
289
+ const record = value;
290
+ return typeof record.id === "string" &&
291
+ typeof record.name === "string" &&
292
+ typeof record.codeHash === "string" &&
293
+ typeof record.expiresAt === "string";
294
+ }
@@ -0,0 +1,52 @@
1
+ export const PEER_PROTOCOL_VERSION = 1;
2
+ export const DEFAULT_PEER_SCOPES = [
3
+ "inspect",
4
+ "sessions.read",
5
+ "sessions.write",
6
+ "prompt.send",
7
+ "prompt.abort",
8
+ "queue.read",
9
+ "queue.write",
10
+ "files.read",
11
+ "files.write",
12
+ "diagnostics.read",
13
+ "logs.read",
14
+ ];
15
+ export function publicPeer(record) {
16
+ return {
17
+ id: record.id,
18
+ name: record.name,
19
+ url: record.url,
20
+ nodeId: record.nodeId,
21
+ fingerprint: record.fingerprint,
22
+ tlsFingerprint: record.tlsFingerprint,
23
+ enabled: record.enabled,
24
+ direction: record.direction,
25
+ scopes: [...record.scopes],
26
+ allowedAgents: [...record.allowedAgents],
27
+ allowedWorkspaceRoots: [...record.allowedWorkspaceRoots],
28
+ workspaceAliases: { ...record.workspaceAliases },
29
+ createdAt: record.createdAt,
30
+ updatedAt: record.updatedAt,
31
+ lastSeenAt: record.lastSeenAt,
32
+ lastCheckedAt: record.lastCheckedAt,
33
+ lastLatencyMs: record.lastLatencyMs,
34
+ remoteVersion: record.remoteVersion,
35
+ remoteStatus: record.remoteStatus,
36
+ lastError: record.lastError,
37
+ };
38
+ }
39
+ export function publicInvitation(record) {
40
+ return {
41
+ id: record.id,
42
+ name: record.name,
43
+ expiresAt: record.expiresAt,
44
+ createdAt: record.createdAt,
45
+ scopes: [...record.scopes],
46
+ allowedAgents: [...record.allowedAgents],
47
+ allowedWorkspaceRoots: [...record.allowedWorkspaceRoots],
48
+ workspaceAliases: { ...record.workspaceAliases },
49
+ usedAt: record.usedAt,
50
+ usedByNodeId: record.usedByNodeId,
51
+ };
52
+ }
@@ -2,6 +2,10 @@ import {} from "./agent.js";
2
2
  import { getExternalSnapshotForSession } from "./agent-activity.js";
3
3
  import { friendlyErrorText } from "./error-messages.js";
4
4
  import {} from "./web-state.js";
5
+ const CLI_ACTIVITY_ACTOR = {
6
+ channel: "cli",
7
+ label: "CLI",
8
+ };
5
9
  export class RelayExternalActivityMonitor {
6
10
  options;
7
11
  mirror = null;
@@ -16,7 +20,9 @@ export class RelayExternalActivityMonitor {
16
20
  if (!this.mirror) {
17
21
  return null;
18
22
  }
19
- const startedAt = this.mirror.startedAt ?? new Date().toISOString();
23
+ const startedAt = this.mirror.startedAt instanceof Date
24
+ ? this.mirror.startedAt.toISOString()
25
+ : this.mirror.startedAt ?? new Date().toISOString();
20
26
  const startedMs = new Date(startedAt).getTime();
21
27
  return {
22
28
  id: this.mirror.turnId ?? "cli",
@@ -75,7 +81,7 @@ export class RelayExternalActivityMonitor {
75
81
  startedAt: snapshot.activity.startedAt?.toISOString() ?? null,
76
82
  };
77
83
  if (snapshot.activity.active) {
78
- this.startExternalTurn(snapshot);
84
+ this.startExternalTurn(snapshot, info);
79
85
  }
80
86
  return;
81
87
  }
@@ -85,9 +91,9 @@ export class RelayExternalActivityMonitor {
85
91
  mirror.turnId = snapshot.activity.turnId;
86
92
  mirror.startedAt = snapshot.activity.startedAt?.toISOString() ?? null;
87
93
  mirror.latestAgentLine = undefined;
88
- this.startExternalTurn(snapshot);
94
+ this.startExternalTurn(snapshot, info);
89
95
  }
90
- this.broadcastExternalEvents(snapshot, snapshot.events.filter((event) => event.lineNumber > mirror.lastLine));
96
+ this.broadcastExternalEvents(snapshot, snapshot.events.filter((event) => event.lineNumber > mirror.lastLine), info);
91
97
  mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
92
98
  mirror.latestStatus = externalStatusLine(snapshot, this.options.queueLength());
93
99
  this.options.broadcastStatus(mirror.latestStatus, "info");
@@ -122,6 +128,7 @@ export class RelayExternalActivityMonitor {
122
128
  threadId: snapshot.threadId,
123
129
  workspace: info.workspace,
124
130
  agentId: info.agentId,
131
+ actor: CLI_ACTIVITY_ACTOR,
125
132
  prompt: snapshot.latestUserMessage ?? undefined,
126
133
  detail: `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`,
127
134
  durationMs: durationFromDates(externalStartedAt, terminalEvent.timestamp),
@@ -136,7 +143,7 @@ export class RelayExternalActivityMonitor {
136
143
  }
137
144
  mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
138
145
  }
139
- startExternalTurn(snapshot) {
146
+ startExternalTurn(snapshot, info) {
140
147
  const prompt = snapshot.latestUserMessage ?? `${snapshot.agentLabel} CLI task`;
141
148
  this.options.chatStore.append({
142
149
  threadId: snapshot.threadId,
@@ -158,11 +165,14 @@ export class RelayExternalActivityMonitor {
158
165
  status: "running",
159
166
  type: "cli_turn_started",
160
167
  threadId: snapshot.threadId,
168
+ workspace: info.workspace,
169
+ agentId: info.agentId,
170
+ actor: CLI_ACTIVITY_ACTOR,
161
171
  prompt,
162
172
  detail: `${snapshot.sourceLabel}: ${snapshot.sourcePath}`,
163
173
  });
164
174
  }
165
- broadcastExternalEvents(snapshot, events) {
175
+ broadcastExternalEvents(snapshot, events, info) {
166
176
  for (const event of events) {
167
177
  if (event.kind === "tool" && event.status === "started") {
168
178
  this.options.broadcast({
@@ -176,6 +186,9 @@ export class RelayExternalActivityMonitor {
176
186
  status: "running",
177
187
  type: "cli_tool_started",
178
188
  threadId: snapshot.threadId,
189
+ workspace: info.workspace,
190
+ agentId: info.agentId,
191
+ actor: CLI_ACTIVITY_ACTOR,
179
192
  detail: event.toolName ?? "tool",
180
193
  });
181
194
  }
@@ -186,6 +199,34 @@ export class RelayExternalActivityMonitor {
186
199
  toolCallId: `cli-${event.lineNumber}`,
187
200
  isError: false,
188
201
  });
202
+ this.options.appendActivity({
203
+ source: "cli",
204
+ status: "completed",
205
+ type: "cli_tool_completed",
206
+ threadId: snapshot.threadId,
207
+ workspace: info.workspace,
208
+ agentId: info.agentId,
209
+ actor: CLI_ACTIVITY_ACTOR,
210
+ detail: event.toolName ?? "tool",
211
+ });
212
+ }
213
+ if (event.kind === "tool" && event.status === "failed") {
214
+ this.options.broadcast({
215
+ type: "tool_end",
216
+ id: snapshot.activity.turnId ?? "cli",
217
+ toolCallId: `cli-${event.lineNumber}`,
218
+ isError: true,
219
+ });
220
+ this.options.appendActivity({
221
+ source: "cli",
222
+ status: "failed",
223
+ type: "cli_tool_failed",
224
+ threadId: snapshot.threadId,
225
+ workspace: info.workspace,
226
+ agentId: info.agentId,
227
+ actor: CLI_ACTIVITY_ACTOR,
228
+ detail: event.toolName ?? "tool",
229
+ });
189
230
  }
190
231
  }
191
232
  }