@desplega.ai/agent-swarm 1.71.0 → 1.71.2

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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.71.0",
5
+ "version": "1.71.2",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -6392,6 +6392,27 @@
6392
6392
  }
6393
6393
  }
6394
6394
  },
6395
+ "/api/trackers/jira/disconnect": {
6396
+ "delete": {
6397
+ "summary": "Fully disconnect Jira: delete all webhooks, drop tokens, clear metadata",
6398
+ "tags": [
6399
+ "Trackers"
6400
+ ],
6401
+ "security": [
6402
+ {
6403
+ "bearerAuth": []
6404
+ }
6405
+ ],
6406
+ "responses": {
6407
+ "200": {
6408
+ "description": "Disconnected"
6409
+ },
6410
+ "503": {
6411
+ "description": "Jira not configured"
6412
+ }
6413
+ }
6414
+ }
6415
+ },
6395
6416
  "/api/trackers/linear/authorize": {
6396
6417
  "get": {
6397
6418
  "summary": "Redirect to Linear OAuth consent screen",
@@ -6488,6 +6509,27 @@
6488
6509
  }
6489
6510
  }
6490
6511
  },
6512
+ "/api/trackers/linear/disconnect": {
6513
+ "delete": {
6514
+ "summary": "Fully disconnect Linear: revoke OAuth grant + drop tokens",
6515
+ "tags": [
6516
+ "Trackers"
6517
+ ],
6518
+ "security": [
6519
+ {
6520
+ "bearerAuth": []
6521
+ }
6522
+ ],
6523
+ "responses": {
6524
+ "200": {
6525
+ "description": "Disconnected"
6526
+ },
6527
+ "503": {
6528
+ "description": "Linear not configured"
6529
+ }
6530
+ }
6531
+ }
6532
+ },
6491
6533
  "/api/github/webhook": {
6492
6534
  "post": {
6493
6535
  "summary": "Handle GitHub webhook events",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.71.0",
3
+ "version": "1.71.2",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -1,13 +1,13 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
- import { getOAuthTokens } from "../../be/db-queries/oauth";
3
+ import { deleteOAuthTokens, getOAuthTokens } from "../../be/db-queries/oauth";
4
4
  import { isJiraEnabled } from "../../jira/app";
5
- import { getJiraMetadata } from "../../jira/metadata";
5
+ import { clearJiraMetadata, getJiraMetadata } from "../../jira/metadata";
6
6
  import { getJiraAuthorizationUrl, handleJiraCallback } from "../../jira/oauth";
7
7
  import { handleJiraWebhook } from "../../jira/webhook";
8
8
  import { deleteJiraWebhook, registerJiraWebhook } from "../../jira/webhook-lifecycle";
9
9
  import { route } from "../route-def";
10
- import { parseQueryParams } from "../utils";
10
+ import { deriveApiBaseUrl, parseQueryParams } from "../utils";
11
11
 
12
12
  const MANUAL_WEBHOOK_INSTRUCTIONS =
13
13
  "See docs-site/.../guides/jira-integration.mdx for manual webhook registration steps.";
@@ -112,15 +112,31 @@ const jiraWebhookDelete = route({
112
112
  },
113
113
  });
114
114
 
115
+ // Admin: full disconnect — delete all registered Atlassian webhooks, drop
116
+ // stored OAuth tokens, and clear cloudId/siteUrl/webhookIds metadata. Atlassian
117
+ // 3LO has no public token revocation endpoint, so the OAuth grant itself must
118
+ // be revoked by the user via id.atlassian.com → Connected apps.
119
+ const jiraDisconnect = route({
120
+ method: "delete",
121
+ path: "/api/trackers/jira/disconnect",
122
+ pattern: ["api", "trackers", "jira", "disconnect"],
123
+ summary: "Fully disconnect Jira: delete all webhooks, drop tokens, clear metadata",
124
+ tags: ["Trackers"],
125
+ responses: {
126
+ 200: { description: "Disconnected" },
127
+ 503: { description: "Jira not configured" },
128
+ },
129
+ });
130
+
115
131
  // ─── Helpers ─────────────────────────────────────────────────────────────────
116
132
 
117
- function getWebhookBaseUrl(): string {
118
- return process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
133
+ function getWebhookUrl(req: IncomingMessage): string {
134
+ const token = process.env.JIRA_WEBHOOK_TOKEN ?? "<unset>";
135
+ return `${deriveApiBaseUrl(req)}/api/trackers/jira/webhook/${token}`;
119
136
  }
120
137
 
121
- function getWebhookUrl(): string {
122
- const token = process.env.JIRA_WEBHOOK_TOKEN ?? "<unset>";
123
- return `${getWebhookBaseUrl()}/api/trackers/jira/webhook/${token}`;
138
+ function getRedirectUri(req: IncomingMessage): string {
139
+ return `${deriveApiBaseUrl(req)}/api/trackers/jira/callback`;
124
140
  }
125
141
 
126
142
  // ─── Handler ─────────────────────────────────────────────────────────────────
@@ -218,7 +234,8 @@ export async function handleJiraTracker(
218
234
  scope,
219
235
  hasManageWebhookScope,
220
236
  webhookTokenConfigured: Boolean(process.env.JIRA_WEBHOOK_TOKEN),
221
- webhookUrl: getWebhookUrl(),
237
+ webhookUrl: getWebhookUrl(req),
238
+ redirectUri: getRedirectUri(req),
222
239
  webhookIds: meta.webhookIds ?? [],
223
240
  };
224
241
 
@@ -327,5 +344,52 @@ export async function handleJiraTracker(
327
344
  return true;
328
345
  }
329
346
 
347
+ // DELETE /api/trackers/jira/disconnect — full cleanup.
348
+ if (jiraDisconnect.match(req.method, pathSegments)) {
349
+ if (!isJiraEnabled()) {
350
+ res.writeHead(503, { "Content-Type": "application/json" });
351
+ res.end(JSON.stringify({ error: "Jira integration not configured" }));
352
+ return true;
353
+ }
354
+
355
+ const meta = getJiraMetadata();
356
+ const ids = (meta.webhookIds ?? []).map((entry) => entry.id);
357
+
358
+ let webhooksDeleted = 0;
359
+ const webhookFailures: Array<{ id: number; error: string }> = [];
360
+ for (const id of ids) {
361
+ try {
362
+ await deleteJiraWebhook(id);
363
+ webhooksDeleted++;
364
+ } catch (err) {
365
+ const message = err instanceof Error ? err.message : String(err);
366
+ console.warn(`[Jira] Disconnect: webhook delete failed (id=${id}): ${message}`);
367
+ webhookFailures.push({ id, error: message });
368
+ }
369
+ }
370
+
371
+ deleteOAuthTokens("jira");
372
+ clearJiraMetadata();
373
+
374
+ console.log(
375
+ `[Jira] Disconnected: ${webhooksDeleted}/${ids.length} webhooks deleted, tokens cleared, metadata reset`,
376
+ );
377
+
378
+ res.writeHead(200, { "Content-Type": "application/json" });
379
+ res.end(
380
+ JSON.stringify({
381
+ disconnected: true,
382
+ webhooksDeleted,
383
+ webhooksTotal: ids.length,
384
+ webhookFailures,
385
+ // Atlassian 3LO has no token revocation endpoint — surface this so
386
+ // the UI can prompt the user to revoke the grant manually if desired.
387
+ revokeNote:
388
+ "Atlassian OAuth grants must be revoked manually at https://id.atlassian.com/manage/connected-apps if you want to fully sever the consent.",
389
+ }),
390
+ );
391
+ return true;
392
+ }
393
+
330
394
  return false;
331
395
  }
@@ -1,11 +1,15 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
- import { getOAuthTokens } from "../../be/db-queries/oauth";
3
+ import { deleteOAuthTokens, getOAuthTokens } from "../../be/db-queries/oauth";
4
4
  import { isLinearEnabled } from "../../linear/app";
5
- import { getLinearAuthorizationUrl, handleLinearCallback } from "../../linear/oauth";
5
+ import {
6
+ getLinearAuthorizationUrl,
7
+ handleLinearCallback,
8
+ revokeLinearToken,
9
+ } from "../../linear/oauth";
6
10
  import { handleLinearWebhook } from "../../linear/webhook";
7
11
  import { route } from "../route-def";
8
- import { parseQueryParams } from "../utils";
12
+ import { deriveApiBaseUrl, parseQueryParams } from "../utils";
9
13
 
10
14
  // ─── Route Definitions ───────────────────────────────────────────────────────
11
15
 
@@ -67,6 +71,21 @@ const linearWebhook = route({
67
71
  },
68
72
  });
69
73
 
74
+ // Admin: full disconnect — best-effort revoke the OAuth grant with Linear,
75
+ // then drop stored tokens. Linear webhooks are configured globally on the
76
+ // OAuth app (not per-tenant), so no per-tenant webhook delete is needed.
77
+ const linearDisconnect = route({
78
+ method: "delete",
79
+ path: "/api/trackers/linear/disconnect",
80
+ pattern: ["api", "trackers", "linear", "disconnect"],
81
+ summary: "Fully disconnect Linear: revoke OAuth grant + drop tokens",
82
+ tags: ["Trackers"],
83
+ responses: {
84
+ 200: { description: "Disconnected" },
85
+ 503: { description: "Linear not configured" },
86
+ },
87
+ });
88
+
70
89
  // ─── Handler ─────────────────────────────────────────────────────────────────
71
90
 
72
91
  export async function handleLinearTracker(
@@ -146,7 +165,7 @@ export async function handleLinearTracker(
146
165
  }
147
166
 
148
167
  const tokens = getOAuthTokens("linear");
149
- const baseUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
168
+ const baseUrl = deriveApiBaseUrl(req);
150
169
 
151
170
  const status = {
152
171
  provider: "linear",
@@ -154,6 +173,7 @@ export async function handleLinearTracker(
154
173
  tokenExpiry: tokens?.expiresAt ?? null,
155
174
  scope: tokens?.scope ?? null,
156
175
  webhookUrl: `${baseUrl}/api/trackers/linear/webhook`,
176
+ redirectUri: `${baseUrl}/api/trackers/linear/callback`,
157
177
  };
158
178
 
159
179
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -183,5 +203,28 @@ export async function handleLinearTracker(
183
203
  return true;
184
204
  }
185
205
 
206
+ // DELETE /api/trackers/linear/disconnect — full cleanup.
207
+ if (linearDisconnect.match(req.method, pathSegments)) {
208
+ if (!isLinearEnabled()) {
209
+ res.writeHead(503, { "Content-Type": "application/json" });
210
+ res.end(JSON.stringify({ error: "Linear integration not configured" }));
211
+ return true;
212
+ }
213
+
214
+ const tokens = getOAuthTokens("linear");
215
+ let revoked = false;
216
+ if (tokens?.accessToken) {
217
+ revoked = await revokeLinearToken(tokens.accessToken);
218
+ }
219
+
220
+ deleteOAuthTokens("linear");
221
+
222
+ console.log(`[Linear] Disconnected: revoke=${revoked}, tokens cleared`);
223
+
224
+ res.writeHead(200, { "Content-Type": "application/json" });
225
+ res.end(JSON.stringify({ disconnected: true, revoked }));
226
+ return true;
227
+ }
228
+
186
229
  return false;
187
230
  }
package/src/http/utils.ts CHANGED
@@ -57,6 +57,33 @@ export function jsonError(res: ServerResponse, error: string, status = 400) {
57
57
  res.end(JSON.stringify({ error }));
58
58
  }
59
59
 
60
+ /**
61
+ * Derive the API base URL for outbound-facing values (webhook URLs, OAuth
62
+ * redirect URIs). Returns a URL with no trailing slash.
63
+ *
64
+ * Resolution order:
65
+ * 1. `MCP_BASE_URL` env (canonical)
66
+ * 2. Inbound request host — `X-Forwarded-Proto`/`X-Forwarded-Host` if behind
67
+ * a proxy/tunnel (ngrok), else `Host` header. Lets the URL stay correct
68
+ * when MCP_BASE_URL is unset and the API is reached via an arbitrary
69
+ * external hostname.
70
+ * 3. `http://localhost:<PORT>` fallback
71
+ */
72
+ export function deriveApiBaseUrl(req: IncomingMessage): string {
73
+ const envBase = process.env.MCP_BASE_URL?.trim();
74
+ if (envBase) return envBase.replace(/\/+$/, "");
75
+
76
+ const fwdProtoRaw = req.headers["x-forwarded-proto"];
77
+ const fwdHostRaw = req.headers["x-forwarded-host"];
78
+ const fwdProto = Array.isArray(fwdProtoRaw) ? fwdProtoRaw[0] : fwdProtoRaw;
79
+ const fwdHost = Array.isArray(fwdHostRaw) ? fwdHostRaw[0] : fwdHostRaw;
80
+ const proto = fwdProto?.split(",")[0]?.trim() || "http";
81
+ const host = fwdHost?.split(",")[0]?.trim() || req.headers.host;
82
+
83
+ if (host) return `${proto}://${host}`;
84
+ return `http://localhost:${process.env.PORT || "3013"}`;
85
+ }
86
+
60
87
  /**
61
88
  * Match a route pattern against HTTP method and path segments.
62
89
  *
package/src/jira/app.ts CHANGED
@@ -36,9 +36,15 @@ export function initJira(): boolean {
36
36
 
37
37
  const clientId = process.env.JIRA_CLIENT_ID!;
38
38
  const clientSecret = process.env.JIRA_CLIENT_SECRET ?? "";
39
- const redirectUri =
40
- process.env.JIRA_REDIRECT_URI ??
41
- `http://localhost:${process.env.PORT || "3013"}/api/trackers/jira/callback`;
39
+ // Boot-time redirect URI gets persisted into oauth_apps.redirectUri and used
40
+ // verbatim by the OAuth flow — so it must match what's registered with
41
+ // Atlassian. Prefer MCP_BASE_URL over the localhost dev default; in prod
42
+ // with no JIRA_REDIRECT_URI set, this is what stops Atlassian from sending
43
+ // the user back to localhost.
44
+ const apiBaseUrl =
45
+ process.env.MCP_BASE_URL?.trim().replace(/\/+$/, "") ||
46
+ `http://localhost:${process.env.PORT || "3013"}`;
47
+ const redirectUri = process.env.JIRA_REDIRECT_URI ?? `${apiBaseUrl}/api/trackers/jira/callback`;
42
48
 
43
49
  upsertOAuthApp("jira", {
44
50
  clientId,
@@ -60,6 +66,24 @@ export function initJira(): boolean {
60
66
  initJiraOutboundSync();
61
67
  startJiraWebhookKeepalive();
62
68
 
69
+ warnIfMcpBaseUrlLooksLikeAppUrl();
70
+
63
71
  console.log("[Jira] Integration initialized");
64
72
  return true;
65
73
  }
74
+
75
+ /**
76
+ * Soft sanity check for `MCP_BASE_URL`. If it equals `APP_URL` (a common
77
+ * misconfig that points the webhook URL at the dashboard host), warn loudly
78
+ * so the operator can fix the env. We don't fail boot — Atlassian will just
79
+ * 404 webhook deliveries until corrected.
80
+ */
81
+ function warnIfMcpBaseUrlLooksLikeAppUrl(): void {
82
+ const mcp = process.env.MCP_BASE_URL?.trim().replace(/\/+$/, "");
83
+ const app = process.env.APP_URL?.trim().replace(/\/+$/, "");
84
+ if (mcp && app && mcp === app) {
85
+ console.warn(
86
+ `[Jira] WARNING: MCP_BASE_URL (${mcp}) equals APP_URL — registered webhook URLs will hit the dashboard host, not the API. Atlassian will likely 404 webhook deliveries. Point MCP_BASE_URL at the API server.`,
87
+ );
88
+ }
89
+ }
@@ -102,3 +102,16 @@ export function updateJiraMetadata(partial: Partial<JiraOAuthAppMetadata>): void
102
102
 
103
103
  txn();
104
104
  }
105
+
106
+ /**
107
+ * Reset the Jira `oauth_apps.metadata` blob to `{}`. Used by the disconnect
108
+ * flow to drop cloudId, siteUrl, and webhookIds in one shot. The row itself
109
+ * stays — `initJira()` requires the `oauth_apps` row to exist.
110
+ */
111
+ export function clearJiraMetadata(): void {
112
+ getDb()
113
+ .query(
114
+ "UPDATE oauth_apps SET metadata = '{}', updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE provider = 'jira'",
115
+ )
116
+ .run();
117
+ }
package/src/linear/app.ts CHANGED
@@ -27,9 +27,15 @@ export function initLinear(): boolean {
27
27
 
28
28
  const clientId = process.env.LINEAR_CLIENT_ID!;
29
29
  const clientSecret = process.env.LINEAR_CLIENT_SECRET ?? "";
30
+ // Boot-time redirect URI gets persisted into oauth_apps.redirectUri and used
31
+ // verbatim by the OAuth flow. Prefer MCP_BASE_URL over the localhost default
32
+ // so prod doesn't send users back to localhost when LINEAR_REDIRECT_URI is
33
+ // unset.
34
+ const apiBaseUrl =
35
+ process.env.MCP_BASE_URL?.trim().replace(/\/+$/, "") ||
36
+ `http://localhost:${process.env.PORT || "3013"}`;
30
37
  const redirectUri =
31
- process.env.LINEAR_REDIRECT_URI ??
32
- `http://localhost:${process.env.PORT || "3013"}/api/trackers/linear/callback`;
38
+ process.env.LINEAR_REDIRECT_URI ?? `${apiBaseUrl}/api/trackers/linear/callback`;
33
39
 
34
40
  upsertOAuthApp("linear", {
35
41
  clientId,
@@ -43,6 +49,23 @@ export function initLinear(): boolean {
43
49
 
44
50
  initLinearOutboundSync();
45
51
 
52
+ warnIfMcpBaseUrlLooksLikeAppUrl();
53
+
46
54
  console.log("[Linear] Integration initialized");
47
55
  return true;
48
56
  }
57
+
58
+ /**
59
+ * Soft sanity check for `MCP_BASE_URL`. If it equals `APP_URL` (a common
60
+ * misconfig that surfaces a wrong-looking webhook URL in the dashboard),
61
+ * warn loudly so the operator can fix the env.
62
+ */
63
+ function warnIfMcpBaseUrlLooksLikeAppUrl(): void {
64
+ const mcp = process.env.MCP_BASE_URL?.trim().replace(/\/+$/, "");
65
+ const app = process.env.APP_URL?.trim().replace(/\/+$/, "");
66
+ if (mcp && app && mcp === app) {
67
+ console.warn(
68
+ `[Linear] WARNING: MCP_BASE_URL (${mcp}) equals APP_URL — surfaced webhook URL points at the dashboard host, not the API. Configure Linear with this URL only if the dashboard host also serves /api/*.`,
69
+ );
70
+ }
71
+ }
@@ -33,3 +33,27 @@ export async function handleLinearCallback(
33
33
  if (!config) throw new Error("Linear OAuth not configured");
34
34
  return exchangeCode(config, code, state);
35
35
  }
36
+
37
+ /**
38
+ * Revoke an OAuth access token with Linear. Best-effort — caller should not
39
+ * abort the disconnect flow if this fails. Linear's revocation endpoint is
40
+ * `POST https://api.linear.app/oauth/revoke` with the access token in the
41
+ * Authorization header (per https://developers.linear.app/docs/oauth/authentication).
42
+ *
43
+ * Returns true on a 2xx response, false otherwise.
44
+ */
45
+ export async function revokeLinearToken(accessToken: string): Promise<boolean> {
46
+ try {
47
+ const res = await fetch("https://api.linear.app/oauth/revoke", {
48
+ method: "POST",
49
+ headers: { Authorization: `Bearer ${accessToken}` },
50
+ });
51
+ return res.ok;
52
+ } catch (err) {
53
+ console.warn(
54
+ "[Linear] Token revocation failed (best-effort):",
55
+ err instanceof Error ? err.message : err,
56
+ );
57
+ return false;
58
+ }
59
+ }