@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
@@ -44,6 +44,58 @@ export function renderLoginPage(options) {
44
44
  </body>
45
45
  </html>`;
46
46
  }
47
+ export function renderFirstRunSetupPage(options) {
48
+ return `<!doctype html>
49
+ <html lang="en">
50
+ <head>
51
+ <meta charset="utf-8">
52
+ <meta name="viewport" content="width=device-width, initial-scale=1">
53
+ <title>NordRelay First Run</title>
54
+ <style>
55
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f5f2;color:#181c19;font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}
56
+ form{width:min(460px,calc(100vw - 32px));background:white;border:1px solid #dfe3dc;border-radius:8px;padding:24px;box-shadow:0 20px 60px rgba(20,30,24,.08)}
57
+ h1{font-size:24px;margin:0 0 8px}
58
+ p{color:#5d665d;margin:0 0 18px;line-height:1.45}
59
+ label{display:block;font-size:13px;color:#4b544d;margin:14px 0 6px}
60
+ input{box-sizing:border-box;width:100%;height:40px;border:1px solid #cfd6ce;border-radius:6px;padding:0 10px;font:inherit}
61
+ button{margin-top:18px;width:100%;height:42px;border:0;border-radius:6px;background:#205c43;color:white;font-weight:650;cursor:pointer}
62
+ .error{color:#9b1c1c;min-height:22px;margin-top:12px}
63
+ small{display:block;color:#667267;margin-top:8px;line-height:1.4}
64
+ </style>
65
+ </head>
66
+ <body>
67
+ <form id="setup">
68
+ <h1>NordRelay Setup</h1>
69
+ <p>Create the first admin account. After this, every dashboard page and API route requires login.</p>
70
+ <label>Email</label><input id="email" name="email" type="email" autocomplete="username" required>
71
+ <label>Name</label><input id="displayName" name="displayName" autocomplete="name" required>
72
+ <label>Password</label><input id="password" name="password" type="password" autocomplete="new-password" minlength="12" required>
73
+ <label>Setup token</label><input id="setupToken" name="setupToken" autocomplete="one-time-code" ${options.tokenRequired ? "required" : ""}>
74
+ <small>${options.tokenRequired ? "Use the token printed in the NordRelay console." : "Local setup does not require the token, but the console token also works."}</small>
75
+ <button>Create admin</button>
76
+ <div class="error" id="error"></div>
77
+ </form>
78
+ <script>
79
+ document.getElementById('setup').addEventListener('submit', async (event) => {
80
+ event.preventDefault();
81
+ const payload = {
82
+ email: document.getElementById('email')?.value || undefined,
83
+ displayName: document.getElementById('displayName')?.value || undefined,
84
+ password: document.getElementById('password')?.value || undefined,
85
+ setupToken: document.getElementById('setupToken')?.value || undefined,
86
+ };
87
+ const res = await fetch('/api/setup/admin', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });
88
+ if (!res.ok) {
89
+ const data = await res.json().catch(() => ({}));
90
+ document.getElementById('error').textContent = data.error || 'Setup failed';
91
+ return;
92
+ }
93
+ location.href = '/';
94
+ });
95
+ </script>
96
+ </body>
97
+ </html>`;
98
+ }
47
99
  export function renderDashboardApp() {
48
100
  return `<!doctype html>
49
101
  <html lang="en">
@@ -71,6 +123,7 @@ export function renderDashboardApp() {
71
123
  </div>
72
124
  <div class="header-actions">
73
125
  <span id="connectionStatus" class="badge">Connecting</span>
126
+ <select id="peerSelect" title="NordRelay target"></select>
74
127
  <select id="agentSelect"></select>
75
128
  <button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
76
129
  <button id="refreshBtn">Refresh</button>
@@ -81,7 +134,7 @@ export function renderDashboardApp() {
81
134
  <section class="page active" id="page-overview">
82
135
  <div class="metrics" id="metrics"></div>
83
136
  <div class="stack">
84
- <div class="panel"><h2>Current Session</h2><pre id="sessionText"></pre></div>
137
+ <div class="panel"><h2>Active Sessions</h2><div id="activeSessions" class="list"></div></div>
85
138
  <div class="overview-adapter-grid">
86
139
  <div class="panel"><h2>Agent Adapters</h2><div id="agentAdapters"></div></div>
87
140
  <div class="panel"><h2>Chat Adapters</h2><div id="chatAdapters"></div></div>
@@ -129,6 +182,13 @@ export function renderDashboardApp() {
129
182
  </div>
130
183
  </section>
131
184
 
185
+ <section class="page" id="page-metrics">
186
+ <div class="panel">
187
+ <div class="row"><button id="reloadMetricsBtn">Reload metrics</button></div>
188
+ <div id="metricsPanel" class="list"></div>
189
+ </div>
190
+ </section>
191
+
132
192
  <section class="page" id="page-sessions">
133
193
  <div class="panel">
134
194
  <div class="sessions-toolbar">
@@ -149,7 +209,7 @@ export function renderDashboardApp() {
149
209
 
150
210
  <section class="page" id="page-activity">
151
211
  <div class="panel">
152
- <div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="cli">CLI</option></select><select id="activityStatus"><option value="all">All statuses</option><option value="queued">Queued</option><option value="running">Running</option><option value="completed">Completed</option><option value="failed">Failed</option><option value="aborted">Aborted</option><option value="info">Info</option></select><input id="activitySince" type="datetime-local"><input id="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button><button id="exportActivityBtn" class="secondary">Export</button></div>
212
+ <div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="cli">CLI</option></select><select id="activityCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="activityStatus"><option value="all">All statuses</option><option value="queued">Queued</option><option value="running">Running</option><option value="completed">Completed</option><option value="failed">Failed</option><option value="aborted">Aborted</option><option value="info">Info</option></select><input id="activityActor" placeholder="Actor"><input id="activityAgent" placeholder="Agent"><input id="activityThread" placeholder="Thread ID"><input id="activityWorkspace" placeholder="Workspace"><input id="activityType" placeholder="Type"><input id="activitySince" type="datetime-local"><input id="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button><button id="exportActivityBtn" class="secondary">Export</button></div>
153
213
  <div id="activityList" class="list"></div>
154
214
  </div>
155
215
  </section>
@@ -169,19 +229,55 @@ export function renderDashboardApp() {
169
229
  </div>
170
230
  </section>
171
231
 
232
+ <section class="page" id="page-peers">
233
+ <div class="panel">
234
+ <div class="row"><button id="loadPeersBtn">Reload peers</button><button id="createPeerInviteBtn">Create invite</button><button id="addPeerBtn" class="secondary">Add peer</button></div>
235
+ <div id="peerStatus" class="list"></div>
236
+ <h2>Configured peers</h2>
237
+ <div id="peersList" class="list"></div>
238
+ <h2>Open invitations</h2>
239
+ <div id="peerInvites" class="list"></div>
240
+ </div>
241
+ </section>
242
+
172
243
  <section class="page" id="page-access">
173
244
  <div class="panel">
174
- <div class="row"><button id="loadAccessBtn">Reload users</button><button id="createUserBtn">Create user</button><button id="createGroupBtn" class="secondary">Create group</button><button id="createChatBtn" class="secondary">Add Telegram chat</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
175
- <div id="accessPanel" class="settings-grid"></div>
176
- <h2>Groups</h2>
177
- <div id="groupsList" class="list"></div>
178
- <h2>Telegram chats</h2>
179
- <div id="telegramChatsList" class="list"></div>
180
- <h2>Locks</h2>
181
- <div id="locksList" class="list"></div>
182
- <h2>Audit</h2>
183
- <div class="row"><input id="auditLimit" type="number" value="50" min="1" max="200"><button id="loadAuditBtn">Load audit</button></div>
184
- <div id="auditList" class="list"></div>
245
+ <div class="row"><button id="loadAccessBtn">Reload users</button><button id="createUserBtn">Create user</button><button id="createGroupBtn" class="secondary">Create group</button><button id="createChatBtn" class="secondary">Add Telegram chat</button><button id="createDiscordChannelBtn" class="secondary">Add Discord channel</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
246
+ <div id="accessTabs" class="tabs access-tabs">
247
+ <button type="button" data-access-tab="users" class="active">Users</button>
248
+ <button type="button" data-access-tab="groups">Groups</button>
249
+ <button type="button" data-access-tab="telegram">Telegram</button>
250
+ <button type="button" data-access-tab="discord">Discord</button>
251
+ <button type="button" data-access-tab="locks">Locks</button>
252
+ <button type="button" data-access-tab="audit">Audit</button>
253
+ </div>
254
+ <div class="access-tab active" data-access-tab-panel="users">
255
+ <div id="accessPanel" class="settings-grid"></div>
256
+ </div>
257
+ <div class="access-tab" data-access-tab-panel="groups">
258
+ <h2>Groups</h2>
259
+ <div id="groupsList" class="list"></div>
260
+ </div>
261
+ <div class="access-tab" data-access-tab-panel="telegram">
262
+ <h2>Telegram chats</h2>
263
+ <div id="telegramChatsList" class="list"></div>
264
+ </div>
265
+ <div class="access-tab" data-access-tab-panel="discord">
266
+ <div class="access-tab-heading">
267
+ <h2>Discord channels</h2>
268
+ <input id="discordChannelSearch" placeholder="Search Discord channels">
269
+ </div>
270
+ <div id="discordChannelsList" class="list"></div>
271
+ </div>
272
+ <div class="access-tab" data-access-tab-panel="locks">
273
+ <h2>Locks</h2>
274
+ <div id="locksList" class="list"></div>
275
+ </div>
276
+ <div class="access-tab" data-access-tab-panel="audit">
277
+ <h2>Audit</h2>
278
+ <div class="row"><select id="auditChannel"><option value="all">All channels</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option></select><select id="auditCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="auditStatus"><option value="all">All statuses</option><option value="ok">OK</option><option value="failed">Failed</option><option value="denied">Denied</option></select><input id="auditActor" placeholder="Actor"><input id="auditAgent" placeholder="Agent"><input id="auditThread" placeholder="Thread ID"><input id="auditWorkspace" placeholder="Workspace"><input id="auditSince" type="datetime-local"><input id="auditLimit" type="number" value="50" min="1" max="500"><button id="loadAuditBtn">Load audit</button><button id="exportAuditBtn" class="secondary">Export</button></div>
279
+ <div id="auditList" class="list"></div>
280
+ </div>
185
281
  </div>
186
282
  </section>
187
283
 
@@ -0,0 +1,204 @@
1
+ import { isPermission } from "./access-control.js";
2
+ import { AGENT_IDS, isAgentId } from "./agent.js";
3
+ import { ensurePeerTlsFiles, loadOrCreatePeerIdentity, } from "./peer-identity.js";
4
+ import { pairPeer, RemoteRelayClient } from "./peer-client.js";
5
+ import { PeerStore } from "./peer-store.js";
6
+ import { publicPeer } from "./peer-types.js";
7
+ import { arrayStringField, objectRecord, optionalBooleanField, optionalNumberField, optionalStringField, readJsonBody, sendJson, } from "./web-dashboard-http.js";
8
+ export async function handleDashboardPeerRoute(req, res, url, options) {
9
+ const store = new PeerStore(options.home);
10
+ const identity = loadOrCreatePeerIdentity(options.home, options.config.peerName);
11
+ const tls = options.config.peerTlsEnabled ? ensurePeerTlsFiles(options.home, identity.public) : null;
12
+ if (req.method === "GET" && url.pathname === "/api/peers") {
13
+ sendJson(res, 200, store.snapshot(identity.public, {
14
+ enabled: options.config.peerEnabled,
15
+ listenUrl: peerListenUrl(options.config),
16
+ requireTls: options.config.peerRequireTls,
17
+ }));
18
+ return true;
19
+ }
20
+ if (req.method === "POST" && url.pathname === "/api/peers/invite") {
21
+ const body = await readJsonBody(req);
22
+ const created = store.createInvitation({
23
+ name: optionalStringField(body, "name"),
24
+ expiresInMs: (optionalNumberField(body, "expiresMinutes") ?? 10) * 60 * 1000,
25
+ scopes: parseScopes(arrayStringField(body, "scopes")),
26
+ allowedAgents: parseAgents(arrayStringField(body, "allowedAgents")),
27
+ allowedWorkspaceRoots: arrayStringField(body, "allowedWorkspaceRoots"),
28
+ workspaceAliases: parseWorkspaceAliases(body.workspaceAliases),
29
+ });
30
+ const listenUrl = peerListenUrl(options.config);
31
+ const command = `nordrelay peer add ${listenUrl} --code ${created.code}`;
32
+ sendJson(res, 201, {
33
+ invitation: created.invitation,
34
+ code: created.code,
35
+ url: listenUrl,
36
+ fingerprint: identity.public.fingerprint,
37
+ tlsFingerprint: tls?.fingerprint,
38
+ command,
39
+ });
40
+ options.auditPeerAction?.("peer_invite_created", created.invitation.name);
41
+ return true;
42
+ }
43
+ if (req.method === "POST" && (url.pathname === "/api/peers" || url.pathname === "/api/peers/pair")) {
44
+ const body = await readJsonBody(req);
45
+ const result = await pairPeer({
46
+ url: requiredString(body, "url"),
47
+ code: requiredString(body, "code"),
48
+ name: optionalStringField(body, "name"),
49
+ publicUrl: optionalStringField(body, "publicUrl"),
50
+ }, identity, store);
51
+ sendJson(res, 201, { peer: publicPeer(result.peer), tlsFingerprint: result.tlsFingerprint });
52
+ options.auditPeerAction?.("peer_paired", `${result.peer.name} (${result.peer.id})`);
53
+ return true;
54
+ }
55
+ const peerMatch = url.pathname.match(/^\/api\/peers\/([^/]+)$/);
56
+ if (peerMatch?.[1] && req.method === "PATCH") {
57
+ const body = await readJsonBody(req);
58
+ const peer = store.updatePeer(decodeURIComponent(peerMatch[1]), {
59
+ name: optionalStringField(body, "name"),
60
+ url: optionalStringField(body, "url"),
61
+ enabled: optionalBooleanField(body, "enabled"),
62
+ scopes: body.scopes === undefined ? undefined : parseScopes(arrayStringField(body, "scopes")),
63
+ allowedAgents: body.allowedAgents === undefined ? undefined : parseAgents(arrayStringField(body, "allowedAgents")),
64
+ allowedWorkspaceRoots: body.allowedWorkspaceRoots === undefined ? undefined : arrayStringField(body, "allowedWorkspaceRoots"),
65
+ workspaceAliases: body.workspaceAliases === undefined ? undefined : parseWorkspaceAliases(body.workspaceAliases),
66
+ });
67
+ sendJson(res, 200, { peer: publicPeer(peer) });
68
+ options.auditPeerAction?.("peer_updated", `${peer.name} (${peer.id})`);
69
+ return true;
70
+ }
71
+ if (req.method === "GET" && url.pathname === "/api/peers/global-sessions") {
72
+ const query = optionalStringField(Object.fromEntries(url.searchParams), "query") ?? "";
73
+ const agent = parseAgent(optionalStringField(Object.fromEntries(url.searchParams), "agent"));
74
+ const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 50), 1), 50);
75
+ const client = new RemoteRelayClient(store);
76
+ const targets = [];
77
+ if (options.runtime) {
78
+ targets.push({
79
+ peerId: "local",
80
+ peerName: "Local",
81
+ ok: true,
82
+ data: await options.runtime.listSessionsPage(1, limit, query, agent),
83
+ });
84
+ }
85
+ const peers = store.listPublic().filter((peer) => peer.enabled && peer.url);
86
+ const remoteTargets = await Promise.all(peers.map(async (peer) => {
87
+ try {
88
+ const data = await client.webProxy(peer.id, {
89
+ method: "GET",
90
+ path: "/api/sessions",
91
+ query: { query, page: 1, limit, agent },
92
+ body: {},
93
+ contextKey: "web:dashboard",
94
+ }, options.activityActor, "web:dashboard");
95
+ return { peerId: peer.id, peerName: peer.name, ok: true, data };
96
+ }
97
+ catch (error) {
98
+ return { peerId: peer.id, peerName: peer.name, ok: false, error: error instanceof Error ? error.message : String(error) };
99
+ }
100
+ }));
101
+ sendJson(res, 200, { targets: [...targets, ...remoteTargets] });
102
+ return true;
103
+ }
104
+ if (peerMatch?.[1] && req.method === "DELETE") {
105
+ const peerId = decodeURIComponent(peerMatch[1]);
106
+ const removed = store.revokePeer(peerId);
107
+ sendJson(res, 200, { removed });
108
+ if (removed) {
109
+ options.auditPeerAction?.("peer_revoked", peerId);
110
+ }
111
+ return true;
112
+ }
113
+ const proxyMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/proxy$/);
114
+ if (proxyMatch?.[1] && req.method === "POST") {
115
+ const body = await readJsonBody(req);
116
+ const payload = parseProxyPayload(body);
117
+ const data = await new RemoteRelayClient(store).webProxy(decodeURIComponent(proxyMatch[1]), payload, options.activityActor, payload.contextKey);
118
+ sendJson(res, 200, data);
119
+ return true;
120
+ }
121
+ const healthMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/health$/);
122
+ if (healthMatch?.[1] && req.method === "GET") {
123
+ const peerId = decodeURIComponent(healthMatch[1]);
124
+ const data = await new RemoteRelayClient(store).rpc(peerId, "peer.ping", undefined, options.activityActor);
125
+ sendJson(res, 200, { data, peer: publicPeer(store.get(peerId)) });
126
+ return true;
127
+ }
128
+ const eventsMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/events$/);
129
+ if (eventsMatch?.[1] && req.method === "GET") {
130
+ res.writeHead(200, {
131
+ "content-type": "text/event-stream; charset=utf-8",
132
+ "cache-control": "no-cache, no-transform",
133
+ connection: "keep-alive",
134
+ });
135
+ const sourceContextKey = url.searchParams.get("contextKey") || undefined;
136
+ const subscription = new RemoteRelayClient(store).subscribe(decodeURIComponent(eventsMatch[1]), (event) => {
137
+ if (res.destroyed || res.writableEnded)
138
+ return;
139
+ res.write(`event: ${event.type}\n`);
140
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
141
+ }, (error) => {
142
+ if (!res.destroyed && !res.writableEnded) {
143
+ res.write("event: status\n");
144
+ res.write(`data: ${JSON.stringify({ type: "status", level: "error", message: error.message, at: new Date().toISOString() })}\n\n`);
145
+ }
146
+ }, sourceContextKey);
147
+ const heartbeat = setInterval(() => {
148
+ if (!res.destroyed && !res.writableEnded)
149
+ res.write(": heartbeat\n\n");
150
+ }, 25_000);
151
+ heartbeat.unref?.();
152
+ req.on("close", () => {
153
+ clearInterval(heartbeat);
154
+ subscription.close();
155
+ });
156
+ return true;
157
+ }
158
+ return false;
159
+ }
160
+ function peerListenUrl(config) {
161
+ if (config.peerPublicUrl)
162
+ return config.peerPublicUrl;
163
+ const scheme = config.peerTlsEnabled ? "https" : "http";
164
+ const host = config.peerHost === "0.0.0.0" || config.peerHost === "::" ? "127.0.0.1" : config.peerHost;
165
+ const displayHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
166
+ return `${scheme}://${displayHost}:${config.peerPort}`;
167
+ }
168
+ function parseScopes(values) {
169
+ return values.filter(isPermission);
170
+ }
171
+ function parseAgents(values) {
172
+ const parsed = values.filter(isAgentId);
173
+ return parsed.length > 0 ? parsed : [...AGENT_IDS];
174
+ }
175
+ function parseAgent(value) {
176
+ return value && isAgentId(value) ? value : undefined;
177
+ }
178
+ function parseProxyPayload(body) {
179
+ return {
180
+ method: requiredString(body, "method"),
181
+ path: requiredString(body, "path"),
182
+ query: objectRecord(body.query),
183
+ body: objectRecord(body.body),
184
+ contextKey: optionalStringField(body, "contextKey"),
185
+ };
186
+ }
187
+ function parseWorkspaceAliases(value) {
188
+ if (typeof value === "string") {
189
+ return Object.fromEntries(value.split(",").map((item) => {
190
+ const [alias, workspace] = item.split("=", 2);
191
+ return [alias?.trim() ?? "", workspace?.trim() ?? ""];
192
+ }).filter(([alias, workspace]) => alias && workspace));
193
+ }
194
+ const record = objectRecord(value);
195
+ return Object.fromEntries(Object.entries(record).filter((entry) => typeof entry[1] === "string" && entry[0].trim().length > 0 && entry[1].trim().length > 0));
196
+ }
197
+ function requiredString(body, key) {
198
+ const value = body[key];
199
+ const text = typeof value === "string" ? value.trim() : "";
200
+ if (!text) {
201
+ throw new Error(`${key} is required.`);
202
+ }
203
+ return text;
204
+ }
@@ -11,7 +11,7 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
11
11
  return true;
12
12
  }
13
13
  if (req.method === "POST" && url.pathname === "/api/update") {
14
- sendJson(res, 202, runtime.updateConnector());
14
+ sendJson(res, 202, runtime.updateConnector(options.activityActor));
15
15
  return true;
16
16
  }
17
17
  if (req.method === "GET" && url.pathname === "/api/agent-updates") {
@@ -23,7 +23,7 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
23
23
  const agentId = options.parseAgentIdRequired(stringField(body, "agentId"));
24
24
  const operation = parseAgentUpdateOperation(optionalStringField(body, "operation"));
25
25
  options.assertScopedAgent(authUser, agentId);
26
- sendJson(res, 202, { job: runtime.startAgentUpdate(agentId, operation) });
26
+ sendJson(res, 202, { job: runtime.startAgentUpdate(agentId, operation, options.activityActor) });
27
27
  return true;
28
28
  }
29
29
  const agentUpdateLogMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/log$/);
@@ -36,7 +36,7 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
36
36
  if (req.method === "DELETE" && agentUpdateLogMatch?.[1]) {
37
37
  const id = decodeURIComponent(agentUpdateLogMatch[1]);
38
38
  options.assertAgentUpdateJobScope(authUser, id);
39
- sendJson(res, 200, { deletedId: id, job: runtime.deleteAgentUpdateLog(id) });
39
+ sendJson(res, 200, { deletedId: id, job: runtime.deleteAgentUpdateLog(id, options.activityActor) });
40
40
  return true;
41
41
  }
42
42
  const agentUpdateInputMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/input$/);
@@ -44,20 +44,53 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
44
44
  const body = await readJsonBody(req);
45
45
  const id = decodeURIComponent(agentUpdateInputMatch[1]);
46
46
  options.assertAgentUpdateJobScope(authUser, id);
47
- sendJson(res, 200, { job: runtime.sendAgentUpdateInput(id, stringField(body, "input")) });
47
+ sendJson(res, 200, { job: runtime.sendAgentUpdateInput(id, stringField(body, "input"), options.activityActor) });
48
48
  return true;
49
49
  }
50
50
  const agentUpdateCancelMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/cancel$/);
51
51
  if (req.method === "POST" && agentUpdateCancelMatch?.[1]) {
52
52
  const id = decodeURIComponent(agentUpdateCancelMatch[1]);
53
53
  options.assertAgentUpdateJobScope(authUser, id);
54
- sendJson(res, 200, { job: runtime.cancelAgentUpdate(id) });
54
+ sendJson(res, 200, { job: runtime.cancelAgentUpdate(id, options.activityActor) });
55
55
  return true;
56
56
  }
57
57
  if (req.method === "GET" && (url.pathname === "/api/tasks" || url.pathname === "/api/progress")) {
58
58
  sendJson(res, 200, await options.scopedTasks(authUser, runtime.tasks()));
59
59
  return true;
60
60
  }
61
+ if (req.method === "GET" && url.pathname === "/api/metrics") {
62
+ sendJson(res, 200, await runtime.metrics());
63
+ return true;
64
+ }
65
+ if (req.method === "GET" && url.pathname === "/api/jobs") {
66
+ sendJson(res, 200, await runtime.jobs());
67
+ return true;
68
+ }
69
+ const jobLogMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/log$/);
70
+ if (req.method === "GET" && jobLogMatch?.[1]) {
71
+ sendJson(res, 200, await runtime.jobLog(decodeURIComponent(jobLogMatch[1])));
72
+ return true;
73
+ }
74
+ const jobActionMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/action$/);
75
+ if (req.method === "POST" && jobActionMatch?.[1]) {
76
+ const body = await readJsonBody(req);
77
+ const id = decodeURIComponent(jobActionMatch[1]);
78
+ const action = stringField(body, "action");
79
+ if (action !== "cancel" && action !== "retry") {
80
+ throw new Error("Unsupported job action.");
81
+ }
82
+ const permission = permissionForJobAction(id, action);
83
+ if (!users.hasPermission(authUser, permission)) {
84
+ sendJson(res, 403, { error: `Access denied: ${permission} permission required.` });
85
+ return true;
86
+ }
87
+ sendJson(res, 200, await runtime.jobAction(id, action, options.activityActor));
88
+ return true;
89
+ }
90
+ if (req.method === "GET" && url.pathname === "/api/active-sessions") {
91
+ sendJson(res, 200, options.scopedActiveSessions(authUser, await runtime.activeSessions()));
92
+ return true;
93
+ }
61
94
  if (req.method === "GET" && url.pathname === "/api/adapters/health") {
62
95
  sendJson(res, 200, { adapters: (await runtime.adapterHealth()).filter((adapter) => users.canUseAgent(authUser, adapter.id)) });
63
96
  return true;
@@ -70,7 +103,7 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
70
103
  if (req.method === "POST" && url.pathname === "/api/logs/clear") {
71
104
  const body = await readJsonBody(req);
72
105
  const target = parseLogTarget(optionalStringField(body, "target"));
73
- sendJson(res, 200, runtime.clearLogs(target));
106
+ sendJson(res, 200, runtime.clearLogs(target, options.activityActor));
74
107
  return true;
75
108
  }
76
109
  if (req.method === "GET" && url.pathname === "/api/diagnostics") {
@@ -80,13 +113,25 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
80
113
  }
81
114
  if (req.method === "GET" && url.pathname === "/api/diagnostics/bundle") {
82
115
  await options.assertCurrentSessionScope(authUser);
83
- const bundle = await runtime.supportBundle();
116
+ const bundle = await runtime.supportBundle(options.activityActor);
84
117
  sendFile(res, bundle.path, bundle.name);
85
118
  return true;
86
119
  }
87
120
  if (req.method === "POST" && url.pathname === "/api/runtime/restart") {
88
- sendJson(res, 202, runtime.restartConnector());
121
+ sendJson(res, 202, runtime.restartConnector(options.activityActor));
89
122
  return true;
90
123
  }
91
124
  return false;
92
125
  }
126
+ function permissionForJobAction(id, action) {
127
+ if (id === "web:current" && action === "cancel") {
128
+ return "prompt.abort";
129
+ }
130
+ if (id.startsWith("queue:")) {
131
+ return "queue.write";
132
+ }
133
+ if (id.startsWith("support-bundle:")) {
134
+ return "diagnostics.read";
135
+ }
136
+ return "updates.run";
137
+ }