agent-relay-server 0.9.0 → 0.10.1

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 (44) hide show
  1. package/README.md +12 -14
  2. package/package.json +18 -1
  3. package/public/index.html +979 -2575
  4. package/public/manifest.webmanifest +6 -6
  5. package/public/sw.js +16 -10
  6. package/recipes/code-review.yaml +26 -0
  7. package/recipes/debug.yaml +20 -0
  8. package/recipes/feature.yaml +26 -0
  9. package/recipes/refactor.yaml +20 -0
  10. package/recipes/test.yaml +20 -0
  11. package/runner/src/adapter.ts +69 -0
  12. package/runner/src/config.ts +144 -0
  13. package/scripts/orchestrator-spawn-smoke.ts +2 -9
  14. package/src/agent-spawn.ts +2 -94
  15. package/src/automations.ts +774 -0
  16. package/src/bus-outbox.ts +75 -0
  17. package/src/bus.ts +439 -0
  18. package/src/cli.ts +251 -5
  19. package/src/commands-db.ts +160 -0
  20. package/src/config.ts +1 -1
  21. package/src/connectors.ts +29 -9
  22. package/src/daemon.ts +1 -0
  23. package/src/db.ts +241 -34
  24. package/src/events.ts +33 -0
  25. package/src/index.ts +94 -5
  26. package/src/recipe-db.ts +163 -0
  27. package/src/recipe-loader.ts +100 -0
  28. package/src/recipe-runner.ts +206 -0
  29. package/src/recipe-validator.ts +85 -0
  30. package/src/routes.ts +649 -155
  31. package/src/security.ts +128 -2
  32. package/src/sse.ts +42 -31
  33. package/src/token-db.ts +96 -0
  34. package/src/types.ts +1 -493
  35. package/src/upgrade.ts +14 -28
  36. package/public/dashboard/actions.js +0 -819
  37. package/public/dashboard/api.js +0 -336
  38. package/public/dashboard/app.js +0 -34
  39. package/public/dashboard/charts.js +0 -128
  40. package/public/dashboard/computed.js +0 -693
  41. package/public/dashboard/constants.js +0 -28
  42. package/public/dashboard/display.js +0 -345
  43. package/public/dashboard/state.js +0 -129
  44. package/public/dashboard/utils.js +0 -207
package/src/security.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import { AUTH_TOKEN, CORS_ORIGINS, getIntegrationTokens, type IntegrationTokenConfig } from "./config";
2
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { getDb } from "./db";
6
+ import type { ComponentToken } from "./types";
2
7
 
3
8
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
4
9
 
@@ -65,9 +70,11 @@ export function corsPreflight(req: Request): Response {
65
70
 
66
71
  export function isAuthorized(req: Request): boolean {
67
72
  const token = authToken();
68
- if (!token) return true;
73
+ if (!token && !extractToken(req)) return true;
69
74
 
70
- return extractToken(req) === token;
75
+ const presented = extractToken(req);
76
+ if (token && presented === token) return true;
77
+ return Boolean(presented && verifyComponentToken(presented));
71
78
  }
72
79
 
73
80
  type IntegrationAuth = IntegrationTokenConfig;
@@ -98,10 +105,14 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
98
105
  if (pathname === "/api/connectors" || pathname.startsWith("/api/connectors/")) return method === "GET" ? "connectors:read" : "system:write";
99
106
  if (pathname.startsWith("/api/channels") || pathname.startsWith("/api/channel-bindings")) return method === "GET" ? "channels:read" : "channels:write";
100
107
  if (pathname === "/api/integrations" || pathname.startsWith("/api/integrations/")) return method === "GET" ? "integrations:read" : "integrations:write";
108
+ if (pathname.startsWith("/api/automations") || pathname.startsWith("/api/automation-runs")) return method === "GET" ? "automations:read" : "automations:write";
101
109
  if (pathname.startsWith("/api/agents")) return method === "GET" ? "agents:read" : "agents:write";
102
110
  if (pathname.startsWith("/api/activity")) return method === "GET" ? "activity:read" : "activity:write";
103
111
  if (pathname.startsWith("/api/inbox")) return method === "GET" ? "messages:read" : "messages:write";
104
112
  if (pathname.startsWith("/api/messages")) return method === "GET" ? "messages:read" : "messages:write";
113
+ if (pathname.startsWith("/api/commands")) return method === "GET" ? "commands:read" : "commands:write";
114
+ if (pathname.startsWith("/api/recipes")) return method === "GET" ? "recipes:read" : "recipes:write";
115
+ if (pathname.startsWith("/api/tokens")) return "system:write";
105
116
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "tasks:read" : "tasks:write";
106
117
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
107
118
  if (pathname.startsWith("/api/system/")) return "system:write";
@@ -109,6 +120,12 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
109
120
  }
110
121
 
111
122
  export function isScopedRequestAuthorized(req: Request): boolean {
123
+ const component = getComponentAuth(req);
124
+ if (component) {
125
+ const required = requiredComponentScopeFor(req.method, new URL(req.url).pathname);
126
+ return required ? hasComponentScope(component, required) : false;
127
+ }
128
+
112
129
  const auth = getIntegrationAuth(req);
113
130
  if (!auth) return false;
114
131
  const pathname = new URL(req.url).pathname;
@@ -121,6 +138,61 @@ export function isScopedRequestAuthorized(req: Request): boolean {
121
138
  return scope ? hasIntegrationScope(auth, scope) : false;
122
139
  }
123
140
 
141
+ export function getComponentAuth(req: Request): ComponentToken | null {
142
+ const token = extractToken(req);
143
+ return token ? verifyComponentToken(token) : null;
144
+ }
145
+
146
+ export function hasComponentScope(auth: ComponentToken, requiredScope: string): boolean {
147
+ if (auth.scope.includes("admin:*")) return true;
148
+ if (auth.scope.includes(requiredScope)) return true;
149
+ const [category] = requiredScope.split(":");
150
+ return Boolean(category && auth.scope.includes(`${category}:*`));
151
+ }
152
+
153
+ export function requiredComponentScopeFor(method: string, pathname: string): string | null {
154
+ if (pathname === "/api/stats" || pathname === "/api/health") return "agent:read";
155
+ if (pathname === "/api/events") return "message:read";
156
+ if (pathname.startsWith("/api/commands")) {
157
+ if (method === "GET") return "command:*";
158
+ return "command:*";
159
+ }
160
+ if (pathname.startsWith("/api/tokens")) return "admin:*";
161
+ if (pathname === "/api/recipes/start") return "recipe:start";
162
+ if (pathname.startsWith("/api/recipes/") && pathname.endsWith("/stop")) return "recipe:stop";
163
+ if (pathname.startsWith("/api/recipes")) return method === "GET" ? "agent:read" : "recipe:start";
164
+ if (pathname.startsWith("/api/automations") || pathname.startsWith("/api/automation-runs")) return method === "GET" ? "automation:read" : "automation:write";
165
+ if (pathname.startsWith("/api/agents")) return method === "GET" ? "agent:read" : "agent:write";
166
+ if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
167
+ if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
168
+ if (pathname.startsWith("/api/orchestrators")) return method === "GET" ? "agent:read" : "command:*";
169
+ if (pathname.startsWith("/api/system/")) return "admin:*";
170
+ return "admin:*";
171
+ }
172
+
173
+ export function signComponentToken(payload: Omit<ComponentToken, "iat"> & { iat?: number }): string {
174
+ const header = { alg: "HS256", typ: "JWT" };
175
+ const body: ComponentToken = {
176
+ ...payload,
177
+ iat: payload.iat ?? Math.floor(Date.now() / 1000),
178
+ };
179
+ const signingInput = `${base64urlJson(header)}.${base64urlJson(body)}`;
180
+ return `${signingInput}.${hmac(signingInput)}`;
181
+ }
182
+
183
+ export function verifyComponentToken(token: string, nowSeconds = Math.floor(Date.now() / 1000)): ComponentToken | null {
184
+ const parts = token.split(".");
185
+ if (parts.length !== 3) return null;
186
+ const [headerRaw, payloadRaw, signature] = parts as [string, string, string];
187
+ const expected = hmac(`${headerRaw}.${payloadRaw}`);
188
+ if (!safeEqual(signature, expected)) return null;
189
+ const payload = parseBase64urlJson(payloadRaw);
190
+ if (!isComponentToken(payload)) return null;
191
+ if (payload.exp !== undefined && payload.exp <= nowSeconds) return null;
192
+ if (payload.jti && isTokenRevoked(payload.jti)) return null;
193
+ return payload;
194
+ }
195
+
124
196
  export function forbidden(req: Request): Response {
125
197
  return applyCors(req, Response.json({ error: "forbidden" }, { status: 403 }));
126
198
  }
@@ -141,6 +213,60 @@ function extractToken(req: Request): string | null {
141
213
  return bearer ?? req.headers.get("x-agent-relay-token") ?? new URL(req.url).searchParams.get("token");
142
214
  }
143
215
 
216
+ function tokenSecret(): string {
217
+ const env = process.env.AGENT_RELAY_TOKEN_SECRET;
218
+ if (env) return env;
219
+ const path = join(process.env.HOME || ".", ".agent-relay", "token-secret");
220
+ if (existsSync(path)) return readFileSync(path, "utf8").trim();
221
+ const secret = randomBytes(32).toString("base64url");
222
+ mkdirSync(dirname(path), { recursive: true });
223
+ writeFileSync(path, `${secret}\n`, { mode: 0o600 });
224
+ return secret;
225
+ }
226
+
227
+ function hmac(input: string): string {
228
+ return createHmac("sha256", tokenSecret()).update(input).digest("base64url");
229
+ }
230
+
231
+ function base64urlJson(value: unknown): string {
232
+ return Buffer.from(JSON.stringify(value)).toString("base64url");
233
+ }
234
+
235
+ function parseBase64urlJson(raw: string): unknown {
236
+ try {
237
+ return JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ function safeEqual(a: string, b: string): boolean {
244
+ const left = Buffer.from(a);
245
+ const right = Buffer.from(b);
246
+ return left.length === right.length && timingSafeEqual(left, right);
247
+ }
248
+
249
+ function isComponentToken(value: unknown): value is ComponentToken {
250
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
251
+ const record = value as Record<string, unknown>;
252
+ return typeof record.sub === "string" &&
253
+ typeof record.role === "string" &&
254
+ Array.isArray(record.scope) &&
255
+ record.scope.every((scope) => typeof scope === "string") &&
256
+ typeof record.iat === "number" &&
257
+ (record.exp === undefined || typeof record.exp === "number") &&
258
+ (record.jti === undefined || typeof record.jti === "string");
259
+ }
260
+
261
+ function isTokenRevoked(jti: string): boolean {
262
+ try {
263
+ const row = getDb().prepare("SELECT revoked_at FROM tokens WHERE jti = ?").get(jti) as { revoked_at?: number | null } | undefined;
264
+ return Boolean(row?.revoked_at);
265
+ } catch {
266
+ return false;
267
+ }
268
+ }
269
+
144
270
  function corsOrigins(): string[] {
145
271
  const raw = process.env.AGENT_RELAY_CORS_ORIGINS;
146
272
  if (raw === undefined) return CORS_ORIGINS;
package/src/sse.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getAgent, getOrchestrator } from "./db";
2
+ import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
2
3
  import type { Message, Task } from "./types";
3
4
 
4
5
  interface Connection {
@@ -10,6 +11,10 @@ interface Connection {
10
11
 
11
12
  const connections = new Map<string, Connection>();
12
13
  const encoder = new TextEncoder();
14
+ const SSE_KEEPALIVE_MS = 15_000;
15
+ const SSE_RETRY_MS = 5_000;
16
+
17
+ subscribeRelayEvents((event) => fanout(event));
13
18
 
14
19
  export function createSSEStream(agentId: string | null): Response {
15
20
  let connId: string;
@@ -20,12 +25,12 @@ export function createSSEStream(agentId: string | null): Response {
20
25
  const keepalive = setInterval(() => {
21
26
  try { controller.enqueue(encoder.encode(": keepalive\n\n")); }
22
27
  catch { removeConnection(connId); }
23
- }, 30_000);
28
+ }, SSE_KEEPALIVE_MS);
24
29
 
25
30
  connections.set(connId, { id: connId, agentId, controller, keepalive });
26
31
 
27
32
  controller.enqueue(encoder.encode(
28
- `event: connected\ndata: ${JSON.stringify({ connectionId: connId })}\n\n`
33
+ `retry: ${SSE_RETRY_MS}\nevent: connected\ndata: ${JSON.stringify({ connectionId: connId })}\n\n`
29
34
  ));
30
35
  },
31
36
  cancel() {
@@ -37,6 +42,8 @@ export function createSSEStream(agentId: string | null): Response {
37
42
  headers: {
38
43
  "Content-Type": "text/event-stream",
39
44
  "Cache-Control": "no-cache, no-transform",
45
+ "Pragma": "no-cache",
46
+ "Vary": "Accept",
40
47
  "X-Accel-Buffering": "no",
41
48
  },
42
49
  });
@@ -71,7 +78,21 @@ function messageMatchesAgent(msg: Message, agentId: string): boolean {
71
78
  return false;
72
79
  }
73
80
 
74
- export function emitNewMessage(msg: Message) {
81
+ function fanout(event: RelayEvent): void {
82
+ if (event.type === "message.new") {
83
+ sendNewMessage(event.data as unknown as Message);
84
+ return;
85
+ }
86
+ if (event.type === "task.updated" || event.type === "task.created" || event.type === "task.claimed" || event.type === "task.status") {
87
+ sendTaskChanged(event.data as unknown as Task, event.type);
88
+ return;
89
+ }
90
+ for (const conn of connections.values()) {
91
+ send(conn, event.type, event.data);
92
+ }
93
+ }
94
+
95
+ function sendNewMessage(msg: Message): void {
75
96
  for (const conn of connections.values()) {
76
97
  if (!conn.agentId) {
77
98
  send(conn, "message.new", msg);
@@ -83,49 +104,45 @@ export function emitNewMessage(msg: Message) {
83
104
  }
84
105
  }
85
106
 
107
+ export function emitNewMessage(msg: Message) {
108
+ emitRelayEvent({ type: "message.new", source: msg.from, subject: String(msg.id), data: msg as unknown as Record<string, unknown> });
109
+ }
110
+
86
111
  export function emitAgentStatus(agentId: string) {
87
112
  const agent = getAgent(agentId);
88
113
  const data = agent ?? { id: agentId, status: "offline" };
89
- for (const conn of connections.values()) {
90
- send(conn, "agent.status", data);
91
- }
114
+ emitRelayEvent({ type: "agent.status", source: "server", subject: agentId, data: data as unknown as Record<string, unknown> });
92
115
  }
93
116
 
94
117
  export function emitAgentRemoved(agentId: string) {
95
- for (const conn of connections.values()) {
96
- send(conn, "agent.removed", { id: agentId });
97
- }
118
+ emitRelayEvent({ type: "agent.removed", source: "server", subject: agentId, data: { id: agentId } });
98
119
  }
99
120
 
100
121
  export function emitMessageClaimed(messageId: number, claimedBy: string, claimExpiresAt?: number) {
101
- for (const conn of connections.values()) {
102
- send(conn, "message.claimed", { messageId, claimedBy, claimExpiresAt });
103
- }
122
+ emitRelayEvent({ type: "message.claimed", source: "server", subject: String(messageId), data: { messageId, claimedBy, claimExpiresAt } });
104
123
  }
105
124
 
106
125
  export function emitMessageClaimReleased(messageId: number) {
107
- for (const conn of connections.values()) {
108
- send(conn, "message.claim_released", { messageId });
109
- }
126
+ emitRelayEvent({ type: "message.claim_released", source: "server", subject: String(messageId), data: { messageId } });
110
127
  }
111
128
 
112
129
  export function emitMessageDeleted(messageId: number) {
113
- for (const conn of connections.values()) {
114
- send(conn, "message.deleted", { messageId });
115
- }
130
+ emitRelayEvent({ type: "message.deleted", source: "server", subject: String(messageId), data: { messageId } });
116
131
  }
117
132
 
118
- export function emitTaskChanged(task: Task, eventType = "task.updated") {
133
+ function sendTaskChanged(task: Task, eventType = "task.updated"): void {
119
134
  for (const conn of connections.values()) {
120
135
  if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId)) continue;
121
136
  send(conn, eventType, task);
122
137
  }
123
138
  }
124
139
 
140
+ export function emitTaskChanged(task: Task, eventType = "task.updated") {
141
+ emitRelayEvent({ type: eventType, source: task.source, subject: String(task.id), data: task as unknown as Record<string, unknown> });
142
+ }
143
+
125
144
  export function emitChannelActivity(activity: Record<string, unknown>) {
126
- for (const conn of connections.values()) {
127
- send(conn, "channel.activity", activity);
128
- }
145
+ emitRelayEvent({ type: "channel.activity", source: "server", data: activity });
129
146
  }
130
147
 
131
148
  export function getConnectionCount(): number {
@@ -145,19 +162,13 @@ function targetMatchesAgent(target: string, agentId: string): boolean {
145
162
  export function emitOrchestratorStatus(orchestratorId: string) {
146
163
  const orch = getOrchestrator(orchestratorId);
147
164
  const data = orch ?? { id: orchestratorId, status: "offline" };
148
- for (const conn of connections.values()) {
149
- send(conn, "orchestrator.status", data);
150
- }
165
+ emitRelayEvent({ type: "orchestrator.status", source: "server", subject: orchestratorId, data: data as unknown as Record<string, unknown> });
151
166
  }
152
167
 
153
168
  export function emitOrchestratorRemoved(orchestratorId: string) {
154
- for (const conn of connections.values()) {
155
- send(conn, "orchestrator.removed", { id: orchestratorId });
156
- }
169
+ emitRelayEvent({ type: "orchestrator.removed", source: "server", subject: orchestratorId, data: { id: orchestratorId } });
157
170
  }
158
171
 
159
172
  export function emitPoolBindingChanged(bindingId: string, channelId: string, previousAgentId: string | null, newAgentId: string | null) {
160
- for (const conn of connections.values()) {
161
- send(conn, "channel.pool.changed", { bindingId, channelId, previousAgentId, newAgentId, at: Date.now() });
162
- }
173
+ emitRelayEvent({ type: "channel.pool.changed", source: "server", subject: bindingId, data: { bindingId, channelId, previousAgentId, newAgentId, at: Date.now() } });
163
174
  }
@@ -0,0 +1,96 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDb } from "./db";
3
+ import { signComponentToken } from "./security";
4
+ import type { ComponentToken } from "./types";
5
+
6
+ interface TokenRecord {
7
+ jti: string;
8
+ sub: string;
9
+ role: string;
10
+ scope: string[];
11
+ issuedAt: number;
12
+ expiresAt?: number;
13
+ revokedAt?: number;
14
+ createdBy?: string;
15
+ }
16
+
17
+ interface TokenRow {
18
+ jti: string;
19
+ sub: string;
20
+ role: string;
21
+ scope: string;
22
+ issued_at: number;
23
+ expires_at: number | null;
24
+ revoked_at: number | null;
25
+ created_by: string | null;
26
+ }
27
+
28
+ export function createToken(input: {
29
+ sub: string;
30
+ role: string;
31
+ scope?: string[];
32
+ ttlSeconds?: number;
33
+ createdBy?: string;
34
+ }): { token: string; record: TokenRecord } {
35
+ const now = Math.floor(Date.now() / 1000);
36
+ const jti = randomUUID();
37
+ const scope = input.scope?.length ? input.scope : defaultScopes(input.role);
38
+ const expiresAt = input.ttlSeconds ? now + input.ttlSeconds : undefined;
39
+ const payload: ComponentToken = {
40
+ sub: input.sub,
41
+ role: input.role,
42
+ scope,
43
+ iat: now,
44
+ exp: expiresAt,
45
+ jti,
46
+ };
47
+ const token = signComponentToken(payload);
48
+ getDb().prepare(`
49
+ INSERT INTO tokens (jti, sub, role, scope, issued_at, expires_at, created_by)
50
+ VALUES (?, ?, ?, ?, ?, ?, ?)
51
+ `).run(jti, input.sub, input.role, JSON.stringify(scope), now, expiresAt ?? null, input.createdBy ?? null);
52
+ return { token, record: getToken(jti)! };
53
+ }
54
+
55
+ export function listTokens(): TokenRecord[] {
56
+ const rows = getDb().prepare("SELECT * FROM tokens ORDER BY issued_at DESC").all() as TokenRow[];
57
+ return rows.map(rowToToken);
58
+ }
59
+
60
+ export function getToken(jti: string): TokenRecord | null {
61
+ const row = getDb().prepare("SELECT * FROM tokens WHERE jti = ?").get(jti) as TokenRow | undefined;
62
+ return row ? rowToToken(row) : null;
63
+ }
64
+
65
+ export function revokeToken(jti: string): boolean {
66
+ return getDb().prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ? AND revoked_at IS NULL").run(Math.floor(Date.now() / 1000), jti).changes > 0;
67
+ }
68
+
69
+ function defaultScopes(role: string): string[] {
70
+ if (role === "provider" || role === "channel") return ["agent:read", "agent:write", "message:send", "message:read"];
71
+ if (role === "orchestrator") return ["agent:read", "agent:write", "command:*", "task:read", "task:write"];
72
+ if (role === "admin" || role === "dashboard") return ["admin:*"];
73
+ return ["agent:read"];
74
+ }
75
+
76
+ function rowToToken(row: TokenRow): TokenRecord {
77
+ return {
78
+ jti: row.jti,
79
+ sub: row.sub,
80
+ role: row.role,
81
+ scope: parseScope(row.scope),
82
+ issuedAt: row.issued_at,
83
+ expiresAt: row.expires_at ?? undefined,
84
+ revokedAt: row.revoked_at ?? undefined,
85
+ createdBy: row.created_by ?? undefined,
86
+ };
87
+ }
88
+
89
+ function parseScope(raw: string): string[] {
90
+ try {
91
+ const parsed = JSON.parse(raw);
92
+ return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
93
+ } catch {
94
+ return [];
95
+ }
96
+ }