@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.
- package/openapi.json +242 -1
- package/package.json +1 -1
- package/src/be/db-queries/oauth.ts +45 -15
- package/src/be/db-queries/tracker.ts +109 -0
- package/src/be/migrations/043_jira_source.sql +128 -0
- package/src/commands/runner.ts +8 -2
- package/src/hooks/hook.ts +4 -2
- package/src/http/core.ts +21 -26
- package/src/http/index.ts +9 -1
- package/src/http/mcp-oauth.ts +132 -60
- package/src/http/mcp-servers.ts +5 -1
- package/src/http/route-def.ts +19 -0
- package/src/http/trackers/index.ts +13 -0
- package/src/http/trackers/jira.ts +331 -0
- package/src/jira/adf.ts +132 -0
- package/src/jira/app.ts +65 -0
- package/src/jira/client.ts +82 -0
- package/src/jira/index.ts +24 -0
- package/src/jira/metadata.ts +104 -0
- package/src/jira/oauth.ts +98 -0
- package/src/jira/outbound.ts +155 -0
- package/src/jira/sync.ts +534 -0
- package/src/jira/templates.ts +84 -0
- package/src/jira/types.ts +35 -0
- package/src/jira/webhook-lifecycle.ts +363 -0
- package/src/jira/webhook.ts +159 -0
- package/src/oauth/wrapper.ts +11 -1
- package/src/providers/claude-adapter.ts +50 -29
- package/src/server.ts +2 -0
- package/src/tasks/context-key.ts +29 -1
- package/src/telemetry.ts +38 -3
- package/src/tests/claude-adapter.test.ts +143 -1
- package/src/tests/context-key.test.ts +19 -0
- package/src/tests/core-auth.test.ts +142 -0
- package/src/tests/jira-adf.test.ts +239 -0
- package/src/tests/jira-metadata.test.ts +147 -0
- package/src/tests/jira-oauth.test.ts +167 -0
- package/src/tests/jira-outbound-sync.test.ts +334 -0
- package/src/tests/jira-sync.test.ts +327 -0
- package/src/tests/jira-webhook-lifecycle.test.ts +234 -0
- package/src/tests/jira-webhook.test.ts +274 -0
- package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
- package/src/tests/telemetry-init.test.ts +108 -0
- package/src/tests/tool-annotations.test.ts +1 -0
- package/src/tools/slack-post.ts +10 -3
- package/src/tools/slack-start-thread.ts +123 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/tracker/tracker-link-task.ts +1 -1
- package/src/tools/tracker/tracker-map-agent.ts +1 -1
- package/src/tools/tracker/tracker-status.ts +1 -1
- package/src/tools/tracker/tracker-sync-status.ts +1 -1
- package/src/tools/update-profile.ts +5 -2
- package/src/tracker/types.ts +1 -1
- 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 {
|
|
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
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
req.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
package/src/http/mcp-oauth.ts
CHANGED
|
@@ -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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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);
|
package/src/http/mcp-servers.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|
package/src/http/route-def.ts
CHANGED
|
@@ -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
|
}
|