@nordbyte/nordrelay 0.6.0 → 0.8.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 (62) hide show
  1. package/.env.example +52 -0
  2. package/README.md +171 -50
  3. package/dist/access-control.js +6 -1
  4. package/dist/activity-events.js +2 -2
  5. package/dist/adapter-conformance.js +61 -0
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot.js +95 -37
  8. package/dist/channel-adapter.js +44 -11
  9. package/dist/channel-command-catalog.js +94 -0
  10. package/dist/channel-command-core.js +60 -0
  11. package/dist/channel-command-service.js +230 -1
  12. package/dist/channel-mirror-registry.js +84 -0
  13. package/dist/channel-peer-prompt.js +95 -0
  14. package/dist/channel-prompt-engine.js +177 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-lifecycle.js +73 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +82 -8
  19. package/dist/config.js +79 -7
  20. package/dist/context-key.js +42 -0
  21. package/dist/discord-bot.js +173 -342
  22. package/dist/discord-command-surface.js +11 -73
  23. package/dist/index.js +29 -0
  24. package/dist/metrics.js +48 -0
  25. package/dist/peer-auth.js +85 -0
  26. package/dist/peer-client.js +288 -0
  27. package/dist/peer-context.js +21 -0
  28. package/dist/peer-identity.js +127 -0
  29. package/dist/peer-readiness.js +77 -0
  30. package/dist/peer-runtime-service.js +658 -0
  31. package/dist/peer-server.js +220 -0
  32. package/dist/peer-store.js +307 -0
  33. package/dist/peer-types.js +52 -0
  34. package/dist/relay-runtime-helpers.js +210 -0
  35. package/dist/relay-runtime.js +79 -274
  36. package/dist/remote-prompt.js +98 -0
  37. package/dist/settings-wizard-test.js +216 -0
  38. package/dist/slack-artifacts.js +165 -0
  39. package/dist/slack-bot.js +1461 -0
  40. package/dist/slack-channel-runtime.js +147 -0
  41. package/dist/slack-command-surface.js +46 -0
  42. package/dist/slack-diagnostics.js +116 -0
  43. package/dist/slack-rate-limit.js +139 -0
  44. package/dist/telegram-command-menu.js +3 -53
  45. package/dist/telegram-general-commands.js +14 -0
  46. package/dist/telegram-preference-commands.js +23 -127
  47. package/dist/user-management-crypto.js +38 -0
  48. package/dist/user-management-normalize.js +188 -0
  49. package/dist/user-management-types.js +1 -0
  50. package/dist/user-management.js +193 -196
  51. package/dist/web-api-contract.js +16 -0
  52. package/dist/web-dashboard-access-routes.js +62 -0
  53. package/dist/web-dashboard-assets.js +1 -0
  54. package/dist/web-dashboard-pages.js +26 -4
  55. package/dist/web-dashboard-peer-routes.js +225 -0
  56. package/dist/web-dashboard-ui.js +1 -0
  57. package/dist/web-dashboard.js +46 -0
  58. package/dist/web-state.js +2 -2
  59. package/dist/webui-assets/dashboard.css +193 -0
  60. package/dist/webui-assets/dashboard.js +870 -57
  61. package/package.json +5 -2
  62. package/plugins/nordrelay/scripts/nordrelay.mjs +468 -11
@@ -0,0 +1,658 @@
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";
9
+ import { checkPeerEndpoint } from "./peer-client.js";
10
+ export class PeerRuntimeService {
11
+ config;
12
+ runtime;
13
+ options;
14
+ constructor(config, runtime, options = {}) {
15
+ this.config = config;
16
+ this.runtime = runtime;
17
+ this.options = options;
18
+ }
19
+ async handle(peer, request) {
20
+ if (!peer.enabled) {
21
+ throw new Error("Peer is disabled.");
22
+ }
23
+ if (request.type === "web.proxy") {
24
+ return this.handleWebProxy(peer, request.payload, request.actor);
25
+ }
26
+ if (request.type === "peer.ping") {
27
+ this.assertScope(peer, "inspect");
28
+ return { ok: true, status: "online", version: await getPackageVersion(), at: new Date().toISOString() };
29
+ }
30
+ if (request.type === "peer.probe") {
31
+ this.assertScope(peer, "inspect");
32
+ return await this.handlePeerProbe(peer, request.payload);
33
+ }
34
+ throw new Error(`Unsupported peer RPC type: ${request.type}`);
35
+ }
36
+ subscribe(peer, sourceContextKey, send) {
37
+ this.assertScope(peer, "sessions.read");
38
+ const runtime = this.runtimeFor(peer, sourceContextKey);
39
+ return runtime.subscribe((event) => {
40
+ void this.scopeRelayEvent(peer, runtime, event)
41
+ .then((scoped) => {
42
+ if (scoped)
43
+ send(scoped);
44
+ })
45
+ .catch(() => {
46
+ // If a scope check fails for an event, drop that event for this peer.
47
+ });
48
+ });
49
+ }
50
+ async handleWebProxy(peer, payload, actor) {
51
+ const runtime = this.runtimeFor(peer, stringValue(payload?.contextKey) || undefined);
52
+ const method = normalizeMethod(payload?.method);
53
+ const path = normalizePath(payload?.path);
54
+ const query = objectRecord(payload?.query);
55
+ const body = objectRecord(payload?.body);
56
+ const permission = permissionForWebRequest(method, path);
57
+ if (!permission) {
58
+ throw new Error(`Remote endpoint is not allowed: ${method} ${path}`);
59
+ }
60
+ this.assertScope(peer, permission);
61
+ const remoteActor = peerActor(peer, actor);
62
+ if (method === "GET" && path === "/api/bootstrap") {
63
+ const agentId = parseAgentId(query.agent);
64
+ this.assertAgentScope(peer, agentId);
65
+ const status = this.scopedBootstrapStatus(peer, await runtime.bootstrapStatus());
66
+ return {
67
+ auth: {
68
+ user: { id: `peer:${peer.id}`, email: `${peer.name}@peer.local`, displayName: peer.name, active: true },
69
+ groups: [],
70
+ permissions: peer.scopes,
71
+ },
72
+ channels: listChannelDescriptors(),
73
+ agentAdapters: listAgentAdapterDescriptors().filter((adapter) => this.canUseAgent(peer, adapter.id)),
74
+ enabledAgents: enabledAgents(this.config).filter((agentId) => this.canUseAgent(peer, agentId)),
75
+ controls: this.scopedControlOptions(peer, await runtime.controlOptions(agentId)),
76
+ status,
77
+ };
78
+ }
79
+ if (method === "GET" && path === "/api/health")
80
+ return runtime.status();
81
+ if (method === "GET" && path === "/api/snapshot")
82
+ return this.scopedSnapshot(peer, await runtime.snapshot());
83
+ if (method === "GET" && path === "/api/version")
84
+ return runtime.version();
85
+ if (method === "POST" && path === "/api/update")
86
+ return runtime.updateConnector(remoteActor);
87
+ if (method === "GET" && path === "/api/agent-updates") {
88
+ return { jobs: runtime.agentUpdateJobs().filter((job) => this.canUseAgent(peer, job.agentId)) };
89
+ }
90
+ if (method === "POST" && path === "/api/agent-update") {
91
+ const agentId = parseRequiredAgentId(body.agentId);
92
+ this.assertAgentScope(peer, agentId);
93
+ return { job: runtime.startAgentUpdate(agentId, parseAgentUpdateOperation(stringValue(body.operation)), remoteActor) };
94
+ }
95
+ const agentUpdateLogMatch = path.match(/^\/api\/agent-update\/([^/]+)\/log$/);
96
+ if (agentUpdateLogMatch?.[1] && method === "GET") {
97
+ const id = decodeURIComponent(agentUpdateLogMatch[1]);
98
+ this.assertAgentUpdateJobScope(peer, runtime, id);
99
+ return runtime.agentUpdateLog(id);
100
+ }
101
+ if (agentUpdateLogMatch?.[1] && method === "DELETE") {
102
+ const id = decodeURIComponent(agentUpdateLogMatch[1]);
103
+ this.assertAgentUpdateJobScope(peer, runtime, id);
104
+ return { deletedId: id, job: runtime.deleteAgentUpdateLog(id, remoteActor) };
105
+ }
106
+ const agentUpdateInputMatch = path.match(/^\/api\/agent-update\/([^/]+)\/input$/);
107
+ if (agentUpdateInputMatch?.[1] && method === "POST") {
108
+ const id = decodeURIComponent(agentUpdateInputMatch[1]);
109
+ this.assertAgentUpdateJobScope(peer, runtime, id);
110
+ return { job: runtime.sendAgentUpdateInput(id, requiredString(body.input, "input"), remoteActor) };
111
+ }
112
+ const agentUpdateCancelMatch = path.match(/^\/api\/agent-update\/([^/]+)\/cancel$/);
113
+ if (agentUpdateCancelMatch?.[1] && method === "POST") {
114
+ const id = decodeURIComponent(agentUpdateCancelMatch[1]);
115
+ this.assertAgentUpdateJobScope(peer, runtime, id);
116
+ return { job: runtime.cancelAgentUpdate(id, remoteActor) };
117
+ }
118
+ if (method === "GET" && path === "/api/tasks")
119
+ return this.scopedTasks(peer, await runtime.tasks());
120
+ if (method === "GET" && path === "/api/progress")
121
+ return this.scopedTasks(peer, await runtime.tasks());
122
+ if (method === "GET" && path === "/api/metrics")
123
+ return runtime.metrics();
124
+ if (method === "GET" && path === "/api/jobs")
125
+ return this.scopedJobs(peer, await runtime.jobs());
126
+ const jobLogMatch = path.match(/^\/api\/jobs\/([^/]+)\/log$/);
127
+ if (jobLogMatch?.[1] && method === "GET") {
128
+ const id = decodeURIComponent(jobLogMatch[1]);
129
+ const data = await runtime.jobLog(id);
130
+ if (data.job && !this.canUseJob(peer, data.job)) {
131
+ throw new Error("Peer is not allowed to read this job.");
132
+ }
133
+ return data;
134
+ }
135
+ const jobActionMatch = path.match(/^\/api\/jobs\/([^/]+)\/action$/);
136
+ if (jobActionMatch?.[1] && method === "POST") {
137
+ const id = decodeURIComponent(jobActionMatch[1]);
138
+ const action = requiredString(body.action, "action");
139
+ if (action !== "cancel" && action !== "retry") {
140
+ throw new Error("Unsupported job action.");
141
+ }
142
+ this.assertScope(peer, permissionForJobAction(id, action));
143
+ return this.scopedJobs(peer, await runtime.jobAction(id, action, remoteActor));
144
+ }
145
+ if (method === "GET" && path === "/api/active-sessions")
146
+ return this.scopedActiveSessions(peer, await runtime.activeSessions());
147
+ if (method === "GET" && path === "/api/adapters/health") {
148
+ return { adapters: (await runtime.adapterHealth()).filter((adapter) => this.canUseAgent(peer, adapter.id)) };
149
+ }
150
+ if (method === "GET" && path === "/api/diagnostics")
151
+ return this.scopedDiagnostics(peer, await runtime.diagnostics());
152
+ if (method === "GET" && path === "/api/diagnostics/bundle") {
153
+ await this.assertCurrentSessionScope(peer, runtime);
154
+ const bundle = await runtime.supportBundle(remoteActor);
155
+ return {
156
+ ...bundle,
157
+ mimeType: "application/zip",
158
+ dataBase64: readFileSync(bundle.path).toString("base64"),
159
+ };
160
+ }
161
+ if (method === "GET" && path === "/api/control-options") {
162
+ const agentId = parseAgentId(query.agent);
163
+ this.assertAgentScope(peer, agentId);
164
+ return this.scopedControlOptions(peer, await runtime.controlOptions(agentId));
165
+ }
166
+ if (method === "GET" && path === "/api/auth/status") {
167
+ const agentId = parseAgentId(query.agent);
168
+ this.assertAgentScope(peer, agentId);
169
+ return runtime.authStatus(agentId);
170
+ }
171
+ if (method === "POST" && path === "/api/auth/login") {
172
+ const agentId = parseAgentId(body.agentId);
173
+ this.assertAgentScope(peer, agentId);
174
+ return runtime.login(agentId, remoteActor);
175
+ }
176
+ if (method === "POST" && path === "/api/auth/logout") {
177
+ const agentId = parseAgentId(body.agentId);
178
+ this.assertAgentScope(peer, agentId);
179
+ return runtime.logout(agentId, remoteActor);
180
+ }
181
+ if (method === "GET" && path === "/api/sessions") {
182
+ const agentId = parseAgentId(query.agent);
183
+ this.assertAgentScope(peer, agentId);
184
+ return this.scopedSessionPage(peer, await runtime.listSessionsPage(numberValue(query.page, 1), numberValue(query.limit, 50), stringValue(query.query), agentId));
185
+ }
186
+ if (method === "GET" && path === "/api/sessions/detail") {
187
+ const detail = await runtime.sessionDetail(requiredString(query.threadId, "threadId"));
188
+ this.assertSessionDetailScope(peer, detail);
189
+ return detail;
190
+ }
191
+ if (method === "POST" && path === "/api/agent") {
192
+ const agentId = parseRequiredAgentId(body.agentId);
193
+ this.assertAgentScope(peer, agentId);
194
+ return { session: await runtime.setAgent(agentId, remoteActor) };
195
+ }
196
+ if (method === "POST" && path === "/api/sessions/new") {
197
+ const agentId = parseAgentId(body.agentId);
198
+ const workspace = this.resolveWorkspaceAlias(peer, stringValue(body.workspace) || undefined);
199
+ this.assertAgentScope(peer, agentId);
200
+ this.assertWorkspaceScope(peer, workspace);
201
+ return {
202
+ session: await runtime.newSession({
203
+ agentId,
204
+ workspace,
205
+ model: stringValue(body.model) || undefined,
206
+ reasoningEffort: stringValue(body.reasoningEffort) || undefined,
207
+ launchProfileId: stringValue(body.launchProfileId) || undefined,
208
+ fastMode: typeof body.fastMode === "boolean" ? body.fastMode : undefined,
209
+ }, remoteActor),
210
+ };
211
+ }
212
+ if (method === "POST" && path === "/api/sessions/switch") {
213
+ const threadId = requiredString(body.threadId, "threadId");
214
+ this.assertSessionDetailScope(peer, await runtime.sessionDetail(threadId));
215
+ const session = await runtime.switchSession(threadId, remoteActor);
216
+ this.assertSessionScope(peer, session);
217
+ return { session };
218
+ }
219
+ if (method === "POST" && path === "/api/sessions/attach") {
220
+ const threadId = requiredString(body.threadId, "threadId");
221
+ this.assertSessionDetailScope(peer, await runtime.sessionDetail(threadId));
222
+ const session = await runtime.attachSession(threadId, remoteActor);
223
+ this.assertSessionScope(peer, session);
224
+ return { session };
225
+ }
226
+ if (method === "GET" && path === "/api/models") {
227
+ await this.assertCurrentSessionScope(peer, runtime);
228
+ return { models: await runtime.listModels() };
229
+ }
230
+ if (method === "POST" && path === "/api/session/model") {
231
+ await this.assertCurrentSessionScope(peer, runtime);
232
+ return { session: await runtime.setModel(requiredString(body.model, "model"), remoteActor) };
233
+ }
234
+ if (method === "POST" && path === "/api/session/reasoning") {
235
+ await this.assertCurrentSessionScope(peer, runtime);
236
+ return { session: await runtime.setReasoningEffort(requiredString(body.reasoning, "reasoning"), remoteActor) };
237
+ }
238
+ if (method === "POST" && path === "/api/session/fast") {
239
+ await this.assertCurrentSessionScope(peer, runtime);
240
+ return { session: await runtime.setFastMode(Boolean(body.enabled), remoteActor) };
241
+ }
242
+ if (method === "POST" && path === "/api/session/launch") {
243
+ await this.assertCurrentSessionScope(peer, runtime);
244
+ return { session: await runtime.setLaunchProfile(requiredString(body.profileId, "profileId"), remoteActor) };
245
+ }
246
+ if (method === "POST" && path === "/api/prompt") {
247
+ await this.assertCurrentSessionScope(peer, runtime);
248
+ return runtime.sendPrompt(requiredString(body.text, "text"), remoteActor);
249
+ }
250
+ if (method === "POST" && path === "/api/prompt/upload") {
251
+ await this.assertCurrentSessionScope(peer, runtime);
252
+ const files = Array.isArray(body.files) ? body.files.map((file, index) => parseUploadFile(file, index)) : [];
253
+ return runtime.sendUploadPrompt({ text: stringValue(body.text), files }, remoteActor);
254
+ }
255
+ if (method === "POST" && (path === "/api/abort" || path === "/api/stop")) {
256
+ await this.assertCurrentSessionScope(peer, runtime);
257
+ await runtime.abort(remoteActor);
258
+ return { ok: true };
259
+ }
260
+ if (method === "POST" && path === "/api/handback") {
261
+ await this.assertCurrentSessionScope(peer, runtime);
262
+ return runtime.handback(remoteActor);
263
+ }
264
+ if (method === "POST" && path === "/api/retry") {
265
+ await this.assertCurrentSessionScope(peer, runtime);
266
+ return runtime.retry(remoteActor);
267
+ }
268
+ if (method === "POST" && path === "/api/sync") {
269
+ await this.assertCurrentSessionScope(peer, runtime);
270
+ return runtime.sync(remoteActor);
271
+ }
272
+ if (method === "GET" && path === "/api/queue") {
273
+ await this.assertCurrentSessionScope(peer, runtime);
274
+ return { queue: runtime.queue(), paused: runtime.queuePaused() };
275
+ }
276
+ if (method === "POST" && path === "/api/queue") {
277
+ await this.assertCurrentSessionScope(peer, runtime);
278
+ return { queue: runtime.queueAction(requiredString(body.action, "action"), stringValue(body.id) || undefined, remoteActor), paused: runtime.queuePaused() };
279
+ }
280
+ if (method === "GET" && path === "/api/chat/history") {
281
+ await this.assertCurrentSessionScope(peer, runtime);
282
+ return { messages: await runtime.chatHistory(numberValue(query.limit, 200)) };
283
+ }
284
+ if (method === "DELETE" && path === "/api/chat/history") {
285
+ await this.assertCurrentSessionScope(peer, runtime);
286
+ return runtime.clearChatHistory(remoteActor);
287
+ }
288
+ if (method === "GET" && path === "/api/activity") {
289
+ return { events: runtime.activity({ limit: numberValue(query.limit, 100), source: stringValue(query.source) || "all", status: stringValue(query.status) || "all", category: stringValue(query.category) || "all", actor: stringValue(query.actor), agentId: stringValue(query.agentId), threadId: stringValue(query.threadId), workspace: stringValue(query.workspace), type: stringValue(query.type), since: stringValue(query.since) }).filter((event) => this.canUseSession(peer, event)) };
290
+ }
291
+ if (method === "GET" && path === "/api/artifacts") {
292
+ await this.assertCurrentSessionScope(peer, runtime);
293
+ return { reports: await runtime.artifacts() };
294
+ }
295
+ if (method === "GET" && path === "/api/artifacts/preview") {
296
+ await this.assertCurrentSessionScope(peer, runtime);
297
+ return runtime.artifactPreview(requiredString(query.turnId, "turnId"), requiredString(query.path, "path"));
298
+ }
299
+ if (method === "DELETE" && path === "/api/artifacts") {
300
+ await this.assertCurrentSessionScope(peer, runtime);
301
+ return { removed: await runtime.deleteArtifact(requiredString(query.turnId, "turnId"), remoteActor) };
302
+ }
303
+ if (method === "POST" && path === "/api/artifacts/bulk") {
304
+ await this.assertCurrentSessionScope(peer, runtime);
305
+ const action = requiredString(body.action, "action");
306
+ if (action !== "delete")
307
+ throw new Error("Unsupported artifact bulk action.");
308
+ const turnIds = Array.isArray(body.turnIds) ? body.turnIds.filter((item) => typeof item === "string") : [];
309
+ const removed = [];
310
+ for (const turnId of turnIds) {
311
+ if (await runtime.deleteArtifact(turnId, remoteActor))
312
+ removed.push(turnId);
313
+ }
314
+ return { removed };
315
+ }
316
+ if (method === "GET" && path === "/api/artifacts/zip") {
317
+ await this.assertCurrentSessionScope(peer, runtime);
318
+ const bundle = await runtime.createArtifactZip(requiredString(query.turnId, "turnId"), remoteActor);
319
+ if (!bundle)
320
+ throw new Error("Artifact turn not found or ZIP could not be created.");
321
+ return { name: bundle.name, mimeType: "application/zip", dataBase64: readFileSync(bundle.path).toString("base64") };
322
+ }
323
+ if (method === "GET" && path === "/api/artifacts/file") {
324
+ await this.assertCurrentSessionScope(peer, runtime);
325
+ const turnId = requiredString(query.turnId, "turnId");
326
+ const relativePath = requiredString(query.path, "path");
327
+ const report = await runtime.artifact(turnId);
328
+ const artifact = report?.artifacts.find((candidate) => candidate.relativePath === relativePath);
329
+ if (!artifact)
330
+ throw new Error("Artifact not found.");
331
+ return { name: artifact.name, mimeType: mimeTypeFromName(artifact.name), dataBase64: readFileSync(artifact.localPath).toString("base64"), sizeBytes: artifact.sizeBytes };
332
+ }
333
+ if (method === "GET" && path === "/api/logs")
334
+ return runtime.logs((stringValue(query.target) || "connector"), numberValue(query.lines, 100));
335
+ if (method === "POST" && path === "/api/logs/clear")
336
+ return runtime.clearLogs((stringValue(body.target) || "connector"), remoteActor);
337
+ if (method === "POST" && path === "/api/runtime/restart")
338
+ return runtime.restartConnector(remoteActor);
339
+ throw new Error(`Remote endpoint is not implemented: ${method} ${path}`);
340
+ }
341
+ async handlePeerProbe(peer, payload) {
342
+ const requestedUrl = stringValue(objectRecord(payload).url);
343
+ if (!peer.url) {
344
+ throw new Error("Remote probe refused because this peer has no registered URL. Pair with --public-url or set the peer URL first.");
345
+ }
346
+ if (requestedUrl && normalizePeerUrl(requestedUrl) !== normalizePeerUrl(peer.url)) {
347
+ throw new Error("Remote probe refused because the requested URL does not match this peer's registered URL.");
348
+ }
349
+ return await checkPeerEndpoint(peer.url, { expectedTlsFingerprint: peer.tlsFingerprint });
350
+ }
351
+ assertScope(peer, permission) {
352
+ if (!peer.scopes.includes(permission)) {
353
+ throw new Error(`Peer permission denied: ${permission}`);
354
+ }
355
+ }
356
+ runtimeFor(peer, sourceContextKey) {
357
+ return this.options.runtimeForContext?.(peer, sourceContextKey) ?? this.runtime;
358
+ }
359
+ assertAgentScope(peer, agentId) {
360
+ if (agentId && !this.canUseAgent(peer, agentId)) {
361
+ throw new Error(`Peer is not allowed to use agent: ${agentId}`);
362
+ }
363
+ }
364
+ canUseAgent(peer, agentId) {
365
+ return peer.allowedAgents.length === 0 || peer.allowedAgents.includes(agentId);
366
+ }
367
+ assertWorkspaceScope(peer, workspace) {
368
+ const resolved = this.resolveWorkspaceAlias(peer, workspace);
369
+ if (!resolved || peer.allowedWorkspaceRoots.length === 0) {
370
+ return;
371
+ }
372
+ const normalized = resolved.replace(/\\/g, "/");
373
+ const allowed = peer.allowedWorkspaceRoots.some((root) => {
374
+ const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
375
+ return normalized === normalizedRoot || normalized.startsWith(`${normalizedRoot}/`);
376
+ });
377
+ if (!allowed) {
378
+ throw new Error(`Peer is not allowed to use workspace: ${workspace}`);
379
+ }
380
+ }
381
+ async assertCurrentSessionScope(peer, runtime) {
382
+ const snapshot = await runtime.snapshot();
383
+ this.assertSessionScope(peer, snapshot.session);
384
+ }
385
+ assertSessionScope(peer, session) {
386
+ const agentId = typeof session.agentId === "string" ? session.agentId : undefined;
387
+ const workspace = typeof session.workspace === "string"
388
+ ? session.workspace
389
+ : typeof session.cwd === "string"
390
+ ? session.cwd
391
+ : undefined;
392
+ this.assertAgentScope(peer, parseAgentId(agentId));
393
+ this.assertWorkspaceScope(peer, workspace);
394
+ }
395
+ assertSessionDetailScope(peer, detail) {
396
+ const record = objectRecord(detail.record);
397
+ if (Object.keys(record).length > 0) {
398
+ this.assertSessionScope(peer, record);
399
+ }
400
+ }
401
+ canUseSession(peer, session) {
402
+ try {
403
+ this.assertSessionScope(peer, session);
404
+ return true;
405
+ }
406
+ catch {
407
+ return false;
408
+ }
409
+ }
410
+ scopedSnapshot(peer, snapshot) {
411
+ this.assertSessionScope(peer, snapshot.session);
412
+ return {
413
+ ...snapshot,
414
+ enabledAgents: snapshot.enabledAgents.filter((agentId) => this.canUseAgent(peer, agentId)),
415
+ workspaces: uniqueStrings([
416
+ ...Object.keys(peer.workspaceAliases ?? {}),
417
+ ...snapshot.workspaces.filter((workspace) => this.workspaceAllowed(peer, workspace)),
418
+ ]),
419
+ };
420
+ }
421
+ scopedBootstrapStatus(peer, status) {
422
+ const snapshot = status.snapshot;
423
+ if (isRelaySnapshot(snapshot)) {
424
+ return {
425
+ ...status,
426
+ snapshot: this.scopedSnapshot(peer, snapshot),
427
+ };
428
+ }
429
+ return status;
430
+ }
431
+ scopedDiagnostics(peer, diagnostics) {
432
+ return {
433
+ ...diagnostics,
434
+ snapshot: this.scopedSnapshot(peer, diagnostics.snapshot),
435
+ };
436
+ }
437
+ scopedControlOptions(peer, options) {
438
+ return {
439
+ ...options,
440
+ workspaces: uniqueStrings([
441
+ ...Object.keys(peer.workspaceAliases ?? {}),
442
+ ...options.workspaces.filter((workspace) => this.workspaceAllowed(peer, workspace)),
443
+ ]),
444
+ };
445
+ }
446
+ scopedSessionPage(peer, page) {
447
+ return {
448
+ ...page,
449
+ sessions: page.sessions.filter((session) => this.canUseThreadRecord(peer, session)),
450
+ };
451
+ }
452
+ scopedTasks(peer, tasks) {
453
+ const currentAllowed = tasks.current ? this.canUseSession(peer, tasks.current) : true;
454
+ return {
455
+ ...tasks,
456
+ current: tasks.current && this.canUseSession(peer, tasks.current) ? tasks.current : null,
457
+ external: tasks.external && this.canUseSession(peer, tasks.external) ? tasks.external : null,
458
+ queue: currentAllowed ? tasks.queue : [],
459
+ recent: tasks.recent.filter((event) => this.canUseSession(peer, event)),
460
+ };
461
+ }
462
+ scopedActiveSessions(peer, active) {
463
+ return {
464
+ ...active,
465
+ sessions: active.sessions.filter((session) => this.canUseSession(peer, session)),
466
+ };
467
+ }
468
+ scopedJobs(peer, jobs) {
469
+ return {
470
+ ...jobs,
471
+ jobs: jobs.jobs.filter((job) => this.canUseJob(peer, job)),
472
+ };
473
+ }
474
+ canUseJob(peer, job) {
475
+ return this.canUseSession(peer, job);
476
+ }
477
+ assertAgentUpdateJobScope(peer, runtime, id) {
478
+ const job = runtime.agentUpdateJobs().find((candidate) => candidate.id === id);
479
+ if (job) {
480
+ this.assertAgentScope(peer, job.agentId);
481
+ }
482
+ }
483
+ async scopeRelayEvent(peer, runtime, event) {
484
+ switch (event.type) {
485
+ case "snapshot":
486
+ return { ...event, data: this.scopedSnapshot(peer, event.data) };
487
+ case "session_update":
488
+ return this.canUseAgentSessionInfo(peer, event.session) ? event : null;
489
+ case "activity_update":
490
+ return { ...event, events: event.events.filter((item) => this.canUseSession(peer, item)) };
491
+ case "active_sessions_update":
492
+ return { ...event, active: this.scopedActiveSessions(peer, event.active) };
493
+ case "agent_update":
494
+ return this.canUseAgent(peer, event.job.agentId) ? event : null;
495
+ case "status":
496
+ return event;
497
+ case "chat_history":
498
+ case "queue_update":
499
+ case "turn_start":
500
+ case "text_delta":
501
+ case "tool_start":
502
+ case "tool_update":
503
+ case "tool_end":
504
+ case "todo_update":
505
+ case "turn_complete":
506
+ case "turn_error":
507
+ return await this.currentSessionAllowed(peer, runtime) ? event : null;
508
+ }
509
+ }
510
+ async currentSessionAllowed(peer, runtime) {
511
+ try {
512
+ await this.assertCurrentSessionScope(peer, runtime);
513
+ return true;
514
+ }
515
+ catch {
516
+ return false;
517
+ }
518
+ }
519
+ canUseThreadRecord(peer, record) {
520
+ return this.canUseAgent(peer, record.agentId) && this.workspaceAllowed(peer, record.cwd);
521
+ }
522
+ canUseAgentSessionInfo(peer, info) {
523
+ return this.canUseAgent(peer, info.agentId) && this.workspaceAllowed(peer, info.workspace);
524
+ }
525
+ workspaceAllowed(peer, workspace) {
526
+ try {
527
+ this.assertWorkspaceScope(peer, workspace);
528
+ return true;
529
+ }
530
+ catch {
531
+ return false;
532
+ }
533
+ }
534
+ resolveWorkspaceAlias(peer, workspace) {
535
+ if (!workspace)
536
+ return undefined;
537
+ return peer.workspaceAliases?.[workspace] ?? workspace;
538
+ }
539
+ }
540
+ export function peerError(error) {
541
+ return friendlyErrorText(error);
542
+ }
543
+ function peerActor(peer, actor) {
544
+ return {
545
+ channel: "system",
546
+ id: `peer:${peer.id}${actor?.id ? `:${actor.id}` : ""}`,
547
+ label: actor?.label ? `${actor.label} via ${peer.name}` : `Peer ${peer.name}`,
548
+ username: actor?.username,
549
+ channelUserId: actor?.channelUserId,
550
+ };
551
+ }
552
+ function normalizeMethod(value) {
553
+ const method = typeof value === "string" ? value.toUpperCase() : "GET";
554
+ return ["GET", "POST", "PATCH", "PUT", "DELETE"].includes(method) ? method : "GET";
555
+ }
556
+ function normalizePath(value) {
557
+ const path = typeof value === "string" ? value.trim() : "";
558
+ if (!path.startsWith("/api/")) {
559
+ throw new Error("Only /api routes can be proxied.");
560
+ }
561
+ return path;
562
+ }
563
+ function normalizePeerUrl(value) {
564
+ const url = new URL(value);
565
+ url.pathname = "";
566
+ url.search = "";
567
+ url.hash = "";
568
+ return url.toString().replace(/\/$/, "");
569
+ }
570
+ function objectRecord(value) {
571
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
572
+ }
573
+ function parseAgentId(value) {
574
+ const text = stringValue(value);
575
+ return isAgentId(text) ? text : undefined;
576
+ }
577
+ function parseRequiredAgentId(value) {
578
+ const agentId = parseAgentId(value);
579
+ if (!agentId) {
580
+ throw new Error("agentId is required.");
581
+ }
582
+ return agentId;
583
+ }
584
+ function stringValue(value) {
585
+ return typeof value === "string" ? value.trim() : value === undefined || value === null ? "" : String(value).trim();
586
+ }
587
+ function requiredString(value, key) {
588
+ const text = stringValue(value);
589
+ if (!text) {
590
+ throw new Error(`${key} is required.`);
591
+ }
592
+ return text;
593
+ }
594
+ function numberValue(value, fallback) {
595
+ const parsed = typeof value === "number" ? value : Number(stringValue(value));
596
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
597
+ }
598
+ function parseUploadFile(value, index) {
599
+ const record = objectRecord(value);
600
+ const dataBase64 = requiredString(record.dataBase64, `files[${index}].dataBase64`);
601
+ return {
602
+ name: stringValue(record.name) || `upload-${index + 1}`,
603
+ mimeType: stringValue(record.mimeType) || undefined,
604
+ data: Buffer.from(dataBase64.replace(/^data:[^,]+,/, ""), "base64"),
605
+ };
606
+ }
607
+ function isRelaySnapshot(value) {
608
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) &&
609
+ "session" in value &&
610
+ "enabledAgents" in value &&
611
+ "workspaces" in value);
612
+ }
613
+ function parseAgentUpdateOperation(value) {
614
+ if (!value || value === "update") {
615
+ return "update";
616
+ }
617
+ if (value === "install") {
618
+ return "install";
619
+ }
620
+ throw new Error(`Invalid agent update operation: ${value}`);
621
+ }
622
+ function permissionForJobAction(id, action) {
623
+ if (id === "web:current" && action === "cancel") {
624
+ return "prompt.abort";
625
+ }
626
+ if (id.startsWith("queue:")) {
627
+ return "queue.write";
628
+ }
629
+ if (id.startsWith("support-bundle:")) {
630
+ return "diagnostics.read";
631
+ }
632
+ return "updates.run";
633
+ }
634
+ function uniqueStrings(values) {
635
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
636
+ }
637
+ function mimeTypeFromName(name) {
638
+ const lower = name.toLowerCase();
639
+ if (lower.endsWith(".png"))
640
+ return "image/png";
641
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
642
+ return "image/jpeg";
643
+ if (lower.endsWith(".gif"))
644
+ return "image/gif";
645
+ if (lower.endsWith(".webp"))
646
+ return "image/webp";
647
+ if (lower.endsWith(".svg"))
648
+ return "image/svg+xml";
649
+ if (lower.endsWith(".pdf"))
650
+ return "application/pdf";
651
+ if (lower.endsWith(".json"))
652
+ return "application/json";
653
+ if (lower.endsWith(".csv"))
654
+ return "text/csv";
655
+ if (lower.endsWith(".md") || lower.endsWith(".txt") || lower.endsWith(".log"))
656
+ return "text/plain";
657
+ return "application/octet-stream";
658
+ }