@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.
- package/.env.example +80 -11
- package/README.md +154 -22
- package/dist/access-control.js +7 -1
- package/dist/activity-events.js +44 -0
- package/dist/audit-log.js +40 -2
- package/dist/bot-preferences.js +1 -0
- package/dist/bot-rendering.js +10 -7
- package/dist/bot.js +535 -11
- package/dist/channel-actions.js +7 -2
- package/dist/channel-adapter.js +40 -7
- package/dist/channel-command-catalog.js +88 -0
- package/dist/channel-command-service.js +369 -0
- package/dist/channel-mirror-registry.js +77 -0
- package/dist/channel-peer-prompt.js +95 -0
- package/dist/channel-runtime.js +12 -5
- package/dist/channel-turn-service.js +237 -0
- package/dist/codex-state.js +114 -78
- package/dist/config-metadata.js +93 -13
- package/dist/config.js +103 -8
- package/dist/context-key.js +87 -5
- package/dist/discord-artifacts.js +165 -0
- package/dist/discord-bot.js +2073 -0
- package/dist/discord-channel-runtime.js +133 -0
- package/dist/discord-command-surface.js +57 -0
- package/dist/discord-rate-limit.js +141 -0
- package/dist/index.js +36 -5
- package/dist/job-store.js +127 -0
- package/dist/metrics.js +87 -0
- package/dist/peer-auth.js +85 -0
- package/dist/peer-client.js +256 -0
- package/dist/peer-context.js +21 -0
- package/dist/peer-identity.js +127 -0
- package/dist/peer-runtime-service.js +636 -0
- package/dist/peer-server.js +220 -0
- package/dist/peer-store.js +294 -0
- package/dist/peer-types.js +52 -0
- package/dist/relay-external-activity-monitor.js +47 -6
- package/dist/relay-runtime-helpers.js +208 -0
- package/dist/relay-runtime.js +897 -394
- package/dist/remote-prompt.js +98 -0
- package/dist/runtime-cache.js +57 -0
- package/dist/session-locks.js +10 -7
- package/dist/support-bundle.js +1 -0
- package/dist/telegram-access-commands.js +15 -2
- package/dist/telegram-access-middleware.js +16 -3
- package/dist/telegram-agent-commands.js +25 -0
- package/dist/telegram-artifact-commands.js +46 -0
- package/dist/telegram-command-menu.js +3 -53
- package/dist/telegram-diagnostics-command.js +5 -50
- package/dist/telegram-general-commands.js +16 -6
- package/dist/telegram-operational-commands.js +14 -6
- package/dist/telegram-preference-commands.js +23 -127
- package/dist/telegram-queue-commands.js +74 -4
- package/dist/telegram-support-command.js +7 -0
- package/dist/telegram-update-commands.js +27 -0
- package/dist/user-management.js +208 -0
- package/dist/web-api-contract.js +17 -0
- package/dist/web-dashboard-access-routes.js +74 -1
- package/dist/web-dashboard-artifact-routes.js +3 -3
- package/dist/web-dashboard-assets.js +2 -0
- package/dist/web-dashboard-pages.js +109 -13
- package/dist/web-dashboard-peer-routes.js +204 -0
- package/dist/web-dashboard-runtime-routes.js +53 -8
- package/dist/web-dashboard-session-routes.js +27 -20
- package/dist/web-dashboard-ui.js +2 -0
- package/dist/web-dashboard.js +160 -6
- package/dist/web-state.js +33 -2
- package/dist/webui-assets/dashboard.css +75 -1
- package/dist/webui-assets/dashboard.js +779 -55
- package/package.json +5 -2
- 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>
|
|
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="
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<div class="
|
|
184
|
-
|
|
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
|
+
}
|