@desplega.ai/agent-swarm 1.69.1 → 1.71.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 (54) hide show
  1. package/openapi.json +242 -1
  2. package/package.json +1 -1
  3. package/src/be/db-queries/oauth.ts +45 -15
  4. package/src/be/db-queries/tracker.ts +109 -0
  5. package/src/be/migrations/043_jira_source.sql +128 -0
  6. package/src/commands/runner.ts +8 -2
  7. package/src/hooks/hook.ts +4 -2
  8. package/src/http/core.ts +21 -26
  9. package/src/http/index.ts +9 -1
  10. package/src/http/mcp-oauth.ts +132 -60
  11. package/src/http/mcp-servers.ts +5 -1
  12. package/src/http/route-def.ts +19 -0
  13. package/src/http/trackers/index.ts +13 -0
  14. package/src/http/trackers/jira.ts +331 -0
  15. package/src/jira/adf.ts +132 -0
  16. package/src/jira/app.ts +65 -0
  17. package/src/jira/client.ts +82 -0
  18. package/src/jira/index.ts +24 -0
  19. package/src/jira/metadata.ts +104 -0
  20. package/src/jira/oauth.ts +98 -0
  21. package/src/jira/outbound.ts +155 -0
  22. package/src/jira/sync.ts +534 -0
  23. package/src/jira/templates.ts +84 -0
  24. package/src/jira/types.ts +35 -0
  25. package/src/jira/webhook-lifecycle.ts +363 -0
  26. package/src/jira/webhook.ts +159 -0
  27. package/src/oauth/wrapper.ts +11 -1
  28. package/src/providers/claude-adapter.ts +50 -29
  29. package/src/server.ts +2 -0
  30. package/src/tasks/context-key.ts +29 -1
  31. package/src/telemetry.ts +38 -3
  32. package/src/tests/claude-adapter.test.ts +143 -1
  33. package/src/tests/context-key.test.ts +19 -0
  34. package/src/tests/core-auth.test.ts +142 -0
  35. package/src/tests/jira-adf.test.ts +239 -0
  36. package/src/tests/jira-metadata.test.ts +147 -0
  37. package/src/tests/jira-oauth.test.ts +167 -0
  38. package/src/tests/jira-outbound-sync.test.ts +334 -0
  39. package/src/tests/jira-sync.test.ts +327 -0
  40. package/src/tests/jira-webhook-lifecycle.test.ts +234 -0
  41. package/src/tests/jira-webhook.test.ts +274 -0
  42. package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
  43. package/src/tests/telemetry-init.test.ts +108 -0
  44. package/src/tests/tool-annotations.test.ts +1 -0
  45. package/src/tools/slack-post.ts +10 -3
  46. package/src/tools/slack-start-thread.ts +123 -0
  47. package/src/tools/tool-config.ts +2 -1
  48. package/src/tools/tracker/tracker-link-task.ts +1 -1
  49. package/src/tools/tracker/tracker-map-agent.ts +1 -1
  50. package/src/tools/tracker/tracker-status.ts +1 -1
  51. package/src/tools/tracker/tracker-sync-status.ts +1 -1
  52. package/src/tools/update-profile.ts +5 -2
  53. package/src/tracker/types.ts +1 -1
  54. package/src/types.ts +1 -0
package/src/http/core.ts CHANGED
@@ -11,12 +11,14 @@ import {
11
11
  updateAgentStatus,
12
12
  } from "../be/db";
13
13
  import { initGitHub, resetGitHub } from "../github";
14
+ import { initJira, resetJira } from "../jira";
14
15
  import { initLinear, resetLinear } from "../linear";
15
16
  import { startSlackApp, stopSlackApp } from "../slack";
16
17
  import type { AgentStatus } from "../types";
17
18
  import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
18
19
  import { generateOpenApiSpec, SCALAR_HTML } from "./openapi";
19
- import { agentWithCapacity, parseQueryParams } from "./utils";
20
+ import { isPublicRoute } from "./route-def";
21
+ import { agentWithCapacity, getPathSegments, parseQueryParams } from "./utils";
20
22
 
21
23
  /**
22
24
  * Load global swarm_config entries into process.env.
@@ -68,6 +70,9 @@ export async function reloadGlobalConfigsAndIntegrations(): Promise<ReloadConfig
68
70
  resetLinear();
69
71
  if (initLinear()) integrations.push("linear");
70
72
 
73
+ resetJira();
74
+ if (initJira()) integrations.push("jira");
75
+
71
76
  await stopSlackApp();
72
77
  await startSlackApp();
73
78
  integrations.push("slack");
@@ -121,31 +126,21 @@ export async function handleCore(
121
126
  return true;
122
127
  }
123
128
 
124
- // API key authentication (if API_KEY is configured)
125
- // Skip auth for webhooks (they have their own signature verification)
126
- const isGitHubWebhook = req.url?.startsWith("/api/github/webhook");
127
- const isGitLabWebhook = req.url?.startsWith("/api/gitlab/webhook");
128
- const isAgentMailWebhook = req.url?.startsWith("/api/agentmail/webhook");
129
- const isTrackerAuth =
130
- req.url?.startsWith("/api/trackers/linear/authorize") ||
131
- req.url?.startsWith("/api/trackers/linear/callback") ||
132
- req.url?.startsWith("/api/trackers/linear/webhook");
133
- const isWorkflowWebhook = req.url?.startsWith("/api/webhooks/");
134
- if (
135
- apiKey &&
136
- !isGitHubWebhook &&
137
- !isGitLabWebhook &&
138
- !isAgentMailWebhook &&
139
- !isTrackerAuth &&
140
- !isWorkflowWebhook
141
- ) {
142
- const authHeader = req.headers.authorization;
143
- const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
144
-
145
- if (providedKey !== apiKey) {
146
- res.writeHead(401, { "Content-Type": "application/json" });
147
- res.end(JSON.stringify({ error: "Unauthorized" }));
148
- return true;
129
+ // API-key authentication (if API_KEY is configured). Routes that opt out via
130
+ // `route({ auth: { apiKey: false } })` webhooks, OAuth provider callbacks,
131
+ // etc. are skipped based on the central `routeRegistry`. Unknown paths
132
+ // fall through to the bearer check (fail-closed).
133
+ if (apiKey) {
134
+ const pathSegments = getPathSegments(req.url || "");
135
+ if (!isPublicRoute(req.method, pathSegments)) {
136
+ const authHeader = req.headers.authorization;
137
+ const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
138
+
139
+ if (providedKey !== apiKey) {
140
+ res.writeHead(401, { "Content-Type": "application/json" });
141
+ res.end(JSON.stringify({ error: "Unauthorized" }));
142
+ return true;
143
+ }
149
144
  }
150
145
  }
151
146
 
package/src/http/index.ts CHANGED
@@ -12,6 +12,7 @@ import { closeDb, getSwarmConfigs, upsertSwarmConfig } from "../be/db";
12
12
  import { initGitHub } from "../github";
13
13
  import { initGitLab } from "../gitlab";
14
14
  import { stopHeartbeat } from "../heartbeat";
15
+ import { initJira } from "../jira";
15
16
  import { initLinear } from "../linear";
16
17
  import { startSlackApp, stopSlackApp } from "../slack";
17
18
  import { initTelemetry, telemetry } from "../telemetry";
@@ -245,13 +246,17 @@ httpServer
245
246
  );
246
247
  }
247
248
 
248
- // Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false)
249
+ // Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false).
250
+ // The api-server is the sole authority for the install identity — pass
251
+ // generateIfMissing so it mints a new install ID on first boot. Workers
252
+ // must NOT mint (see src/commands/runner.ts).
249
253
  await initTelemetry(
250
254
  "api-server",
251
255
  (key) => getSwarmConfigs({ scope: "global", key })?.[0]?.value,
252
256
  (key, value) => {
253
257
  upsertSwarmConfig({ scope: "global", key, value });
254
258
  },
259
+ { generateIfMissing: true },
255
260
  );
256
261
  telemetry.server("started", { port });
257
262
 
@@ -270,6 +275,9 @@ httpServer
270
275
  // Initialize Linear tracker integration (if configured)
271
276
  initLinear();
272
277
 
278
+ // Initialize Jira tracker integration (if configured)
279
+ initJira();
280
+
273
281
  // Initialize workflow engine (trigger subscriptions + resume listener)
274
282
  initWorkflows();
275
283
 
@@ -158,6 +158,27 @@ const authorizeRoute = route({
158
158
  },
159
159
  });
160
160
 
161
+ const authorizeUrlRoute = route({
162
+ method: "get",
163
+ path: "/api/mcp-oauth/{mcpServerId}/authorize-url",
164
+ pattern: ["api", "mcp-oauth", null, "authorize-url"],
165
+ summary:
166
+ "Build an OAuth authorize URL. Returns JSON so the browser can navigate without losing the Bearer auth header.",
167
+ tags: ["MCP OAuth"],
168
+ auth: { apiKey: true },
169
+ params: z.object({ mcpServerId: z.string() }),
170
+ query: z.object({
171
+ redirect: z.string().optional(),
172
+ userId: z.string().optional(),
173
+ scopes: z.string().optional(),
174
+ }),
175
+ responses: {
176
+ 200: { description: "{ providerUrl: string }" },
177
+ 400: { description: "MCP has no URL / does not require OAuth" },
178
+ 404: { description: "MCP server not found" },
179
+ },
180
+ });
181
+
161
182
  const callbackRoute = route({
162
183
  method: "get",
163
184
  path: "/api/mcp-oauth/callback",
@@ -236,6 +257,86 @@ const manualClientRoute = route({
236
257
  },
237
258
  });
238
259
 
260
+ // ─── Shared authorize flow ───────────────────────────────────────────────────
261
+
262
+ interface AuthorizeFlowQuery {
263
+ redirect?: string;
264
+ userId?: string;
265
+ scopes?: string;
266
+ }
267
+
268
+ /**
269
+ * Discover metadata, DCR-register (or fail), build the authorize URL, and
270
+ * persist the pending session. Returns the provider `providerUrl` the caller
271
+ * should redirect to / respond with. On failure, writes a JSON error response
272
+ * and returns null.
273
+ */
274
+ async function prepareAuthorizeFlow(
275
+ res: ServerResponse,
276
+ mcpServerId: string,
277
+ server: NonNullable<ReturnType<typeof getMcpServerById>>,
278
+ q: AuthorizeFlowQuery,
279
+ ): Promise<string | null> {
280
+ const discovery = await discoverForMcp(server.url!);
281
+ if (!discovery) {
282
+ jsonError(res, "MCP server does not require OAuth", 400);
283
+ return null;
284
+ }
285
+
286
+ let clientId: string | null = null;
287
+ let clientSecret: string | null = null;
288
+ if (discovery.dcrSupported && discovery.registrationEndpoint) {
289
+ const dcr = await registerClient(discovery.registrationEndpoint, {
290
+ client_name: `agent-swarm (${server.name})`,
291
+ redirect_uris: [callbackRedirectUri()],
292
+ grant_types: ["authorization_code", "refresh_token"],
293
+ response_types: ["code"],
294
+ token_endpoint_auth_method: "client_secret_basic",
295
+ application_type: "web",
296
+ scope: (q.scopes ?? discovery.scopes.join(" ")) || undefined,
297
+ });
298
+ clientId = dcr.client_id;
299
+ clientSecret = dcr.client_secret ?? null;
300
+ } else {
301
+ jsonError(
302
+ res,
303
+ "DCR not supported — paste client_id/client_secret via POST /api/mcp-oauth/:id/manual-client first.",
304
+ 400,
305
+ );
306
+ return null;
307
+ }
308
+
309
+ const scopes = q.scopes ? q.scopes.split(" ").filter(Boolean) : discovery.scopes;
310
+
311
+ const built = await buildAuthorizeUrl({
312
+ authorizeUrl: discovery.authorizeUrl,
313
+ tokenUrl: discovery.tokenUrl,
314
+ clientId: clientId!,
315
+ redirectUri: callbackRedirectUri(),
316
+ scopes,
317
+ resource: discovery.resourceUrl,
318
+ });
319
+
320
+ insertMcpOAuthPending({
321
+ state: built.state,
322
+ mcpServerId,
323
+ userId: q.userId ?? null,
324
+ codeVerifier: built.codeVerifier,
325
+ resourceUrl: discovery.resourceUrl,
326
+ authorizationServerIssuer: discovery.authorizationServerIssuer,
327
+ authorizeUrl: discovery.authorizeUrl,
328
+ tokenUrl: discovery.tokenUrl,
329
+ revocationUrl: discovery.revocationUrl,
330
+ scopes: scopes.join(" "),
331
+ dcrClientId: clientId!,
332
+ dcrClientSecret: clientSecret,
333
+ redirectUri: callbackRedirectUri(),
334
+ finalRedirect: q.redirect ?? null,
335
+ });
336
+
337
+ return built.url;
338
+ }
339
+
239
340
  // ─── Handler ─────────────────────────────────────────────────────────────────
240
341
 
241
342
  export async function handleMcpOAuth(
@@ -398,69 +499,40 @@ export async function handleMcpOAuth(
398
499
  if (!server) return true;
399
500
 
400
501
  try {
401
- const discovery = await discoverForMcp(server.url!);
402
- if (!discovery) {
403
- jsonError(res, "MCP server does not require OAuth", 400);
404
- return true;
405
- }
406
-
407
- // Dynamic Client Registration if supported, otherwise expect the user to
408
- // have already called /manual-client.
409
- let clientId: string | null = null;
410
- let clientSecret: string | null = null;
411
- if (discovery.dcrSupported && discovery.registrationEndpoint) {
412
- const dcr = await registerClient(discovery.registrationEndpoint, {
413
- client_name: `agent-swarm (${server.name})`,
414
- redirect_uris: [callbackRedirectUri()],
415
- grant_types: ["authorization_code", "refresh_token"],
416
- response_types: ["code"],
417
- token_endpoint_auth_method: "client_secret_basic",
418
- application_type: "web",
419
- scope: (parsed.query.scopes ?? discovery.scopes.join(" ")) || undefined,
420
- });
421
- clientId = dcr.client_id;
422
- clientSecret = dcr.client_secret ?? null;
423
- } else {
424
- jsonError(
425
- res,
426
- "DCR not supported — paste client_id/client_secret via POST /api/mcp-oauth/:id/manual-client first.",
427
- 400,
428
- );
429
- return true;
430
- }
502
+ const providerUrl = await prepareAuthorizeFlow(
503
+ res,
504
+ parsed.params.mcpServerId,
505
+ server,
506
+ parsed.query,
507
+ );
508
+ if (!providerUrl) return true;
509
+ res.writeHead(302, { Location: providerUrl });
510
+ res.end();
511
+ } catch (err) {
512
+ const message = err instanceof Error ? err.message : String(err);
513
+ jsonError(res, `Authorize failed: ${message}`, 502);
514
+ }
515
+ return true;
516
+ }
431
517
 
432
- const scopes = parsed.query.scopes
433
- ? parsed.query.scopes.split(" ").filter(Boolean)
434
- : discovery.scopes;
435
-
436
- const built = await buildAuthorizeUrl({
437
- authorizeUrl: discovery.authorizeUrl,
438
- tokenUrl: discovery.tokenUrl,
439
- clientId: clientId!,
440
- redirectUri: callbackRedirectUri(),
441
- scopes,
442
- resource: discovery.resourceUrl,
443
- });
518
+ // GET /api/mcp-oauth/:id/authorize-url — JSON variant of /authorize so the
519
+ // dashboard can fetch the provider URL with Bearer auth and then navigate.
520
+ if (authorizeUrlRoute.match(req.method, pathSegments)) {
521
+ const parsed = await authorizeUrlRoute.parse(req, res, pathSegments, queryParams);
522
+ if (!parsed) return true;
444
523
 
445
- insertMcpOAuthPending({
446
- state: built.state,
447
- mcpServerId: parsed.params.mcpServerId,
448
- userId: parsed.query.userId ?? null,
449
- codeVerifier: built.codeVerifier,
450
- resourceUrl: discovery.resourceUrl,
451
- authorizationServerIssuer: discovery.authorizationServerIssuer,
452
- authorizeUrl: discovery.authorizeUrl,
453
- tokenUrl: discovery.tokenUrl,
454
- revocationUrl: discovery.revocationUrl,
455
- scopes: scopes.join(" "),
456
- dcrClientId: clientId!,
457
- dcrClientSecret: clientSecret,
458
- redirectUri: callbackRedirectUri(),
459
- finalRedirect: parsed.query.redirect ?? null,
460
- });
524
+ const server = getMcpOrError(res, parsed.params.mcpServerId);
525
+ if (!server) return true;
461
526
 
462
- res.writeHead(302, { Location: built.url });
463
- res.end();
527
+ try {
528
+ const providerUrl = await prepareAuthorizeFlow(
529
+ res,
530
+ parsed.params.mcpServerId,
531
+ server,
532
+ parsed.query,
533
+ );
534
+ if (!providerUrl) return true;
535
+ json(res, { providerUrl });
464
536
  } catch (err) {
465
537
  const message = err instanceof Error ? err.message : String(err);
466
538
  jsonError(res, `Authorize failed: ${message}`, 502);
@@ -243,7 +243,11 @@ export async function handleMcpServers(
243
243
  try {
244
244
  const token = await ensureMcpToken(server.id);
245
245
  if (token && token.status === "connected") {
246
- const prefix = token.tokenType || "Bearer";
246
+ // Normalize the bearer scheme to capital "Bearer": some resource
247
+ // servers reject the lowercase "bearer" RFC 6749 returns (issue #368).
248
+ // Non-bearer schemes (e.g. "MAC") are preserved verbatim.
249
+ const rawType = token.tokenType || "Bearer";
250
+ const prefix = rawType.toLowerCase() === "bearer" ? "Bearer" : rawType;
247
251
  resolvedHeaders.Authorization = `${prefix} ${token.accessToken}`;
248
252
  } else if (!token) {
249
253
  authError = "No OAuth token for this MCP server";
@@ -60,6 +60,25 @@ interface RouteHandle<TParams, TQuery, TBody> {
60
60
  /** Global registry — populated at import time, read by OpenAPI generator */
61
61
  export const routeRegistry: RouteDef[] = [];
62
62
 
63
+ /**
64
+ * Check whether a request targets a route declared (via the `route()` factory)
65
+ * with `auth: { apiKey: false }` — i.e. one that opts out of the API-key
66
+ * bearer check. Handler files must use the `route()` factory for this to take
67
+ * effect; unknown paths fail closed (auth required).
68
+ */
69
+ export function isPublicRoute(method: string | undefined, pathSegments: string[]): boolean {
70
+ for (const def of routeRegistry) {
71
+ if (def.auth?.apiKey === false) {
72
+ if (
73
+ matchRoute(method, pathSegments, def.method.toUpperCase(), def.pattern, def.exact ?? true)
74
+ ) {
75
+ return true;
76
+ }
77
+ }
78
+ }
79
+ return false;
80
+ }
81
+
63
82
  // ─── Factory ─────────────────────────────────────────────────────────────────
64
83
 
65
84
  export function route<
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { handleJiraTracker } from "./jira";
2
3
  import { handleLinearTracker } from "./linear";
3
4
 
4
5
  export async function handleTrackers(
@@ -6,5 +7,17 @@ export async function handleTrackers(
6
7
  res: ServerResponse,
7
8
  pathSegments: string[],
8
9
  ): Promise<boolean> {
10
+ // Provider-specific dispatch based on the third path segment
11
+ // (e.g. "api", "trackers", "<provider>", ...).
12
+ if (pathSegments[0] === "api" && pathSegments[1] === "trackers") {
13
+ if (pathSegments[2] === "jira") {
14
+ return await handleJiraTracker(req, res, pathSegments);
15
+ }
16
+ if (pathSegments[2] === "linear") {
17
+ return await handleLinearTracker(req, res, pathSegments);
18
+ }
19
+ }
20
+ // Fallback: try Linear (preserves existing behavior for any path that
21
+ // somehow falls through without an explicit provider segment).
9
22
  return await handleLinearTracker(req, res, pathSegments);
10
23
  }