@desplega.ai/agent-swarm 1.89.0 → 1.91.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 (63) hide show
  1. package/README.md +4 -0
  2. package/openapi.json +74 -1
  3. package/package.json +6 -6
  4. package/plugin/skills/composio/SKILL.md +138 -63
  5. package/plugin/skills/composio-gmail/SKILL.md +83 -0
  6. package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
  7. package/plugin/skills/composio-google-docs/SKILL.md +71 -0
  8. package/src/artifact-sdk/server.ts +2 -1
  9. package/src/be/db.ts +28 -0
  10. package/src/be/memory/providers/sqlite-store.ts +6 -1
  11. package/src/be/memory/types.ts +1 -0
  12. package/src/be/modelsdev-cache.json +752 -81
  13. package/src/be/scripts/typecheck.ts +132 -1
  14. package/src/be/seed-scripts/catalog/compound-insights.ts +188 -0
  15. package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
  16. package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
  17. package/src/be/seed-scripts/catalog/tool-usage.ts +56 -0
  18. package/src/be/seed-scripts/index.ts +36 -0
  19. package/src/commands/artifact.ts +3 -2
  20. package/src/commands/profile-sync.ts +310 -0
  21. package/src/commands/runner.ts +91 -1
  22. package/src/heartbeat/heartbeat.ts +54 -7
  23. package/src/hooks/hook.ts +32 -9
  24. package/src/http/index.ts +47 -0
  25. package/src/http/integrations.ts +6 -1
  26. package/src/http/mcp-bridge.ts +117 -0
  27. package/src/http/mcp-oauth.ts +97 -39
  28. package/src/http/memory.ts +5 -2
  29. package/src/http/openapi.ts +2 -2
  30. package/src/http/pages-public.ts +10 -11
  31. package/src/http/pages.ts +7 -11
  32. package/src/http/scripts.ts +24 -1
  33. package/src/http/tasks.ts +2 -0
  34. package/src/http/utils.ts +11 -4
  35. package/src/jira/app.ts +2 -3
  36. package/src/jira/webhook-lifecycle.ts +2 -1
  37. package/src/linear/app.ts +2 -3
  38. package/src/providers/claude-adapter.ts +26 -0
  39. package/src/scripts-runtime/executors/native.ts +1 -0
  40. package/src/scripts-runtime/sdk-allowlist.ts +121 -0
  41. package/src/scripts-runtime/swarm-sdk.ts +198 -3
  42. package/src/scripts-runtime/types/stdlib.d.ts +227 -0
  43. package/src/scripts-runtime/types/swarm-sdk.d.ts +227 -0
  44. package/src/tasks/worker-follow-up.ts +19 -1
  45. package/src/tests/claude-adapter-otel.test.ts +85 -1
  46. package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
  47. package/src/tests/hook-registration-nudge.test.ts +69 -0
  48. package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
  49. package/src/tests/pages-public-html.test.ts +41 -0
  50. package/src/tests/pages-public-json-redirect.test.ts +37 -2
  51. package/src/tests/profile-sync.test.ts +282 -0
  52. package/src/tests/scripts-runtime.test.ts +33 -0
  53. package/src/tests/seed-scripts.test.ts +2 -2
  54. package/src/tools/create-metric.ts +2 -3
  55. package/src/tools/create-page.ts +3 -6
  56. package/src/tools/memory-rate.ts +2 -1
  57. package/src/tools/memory-search.ts +1 -0
  58. package/src/tools/register-kapso-number.ts +2 -4
  59. package/src/tools/request-human-input.ts +2 -1
  60. package/src/tools/script-common.ts +2 -4
  61. package/src/tools/script-run.ts +7 -0
  62. package/src/utils/constants.ts +58 -8
  63. package/templates/skills/swarm-scripts/content.md +46 -7
package/src/http/index.ts CHANGED
@@ -42,6 +42,7 @@ import { handleInboxState } from "./inbox-state";
42
42
  import { handleIntegrations } from "./integrations";
43
43
  import { handleKv } from "./kv";
44
44
  import { handleMcp } from "./mcp";
45
+ import { handleMcpBridge } from "./mcp-bridge";
45
46
  import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from "./mcp-oauth";
46
47
  import { handleMcpServers } from "./mcp-servers";
47
48
  import { handleMcpUser } from "./mcp-user";
@@ -96,9 +97,47 @@ const globalState = globalThis as typeof globalThis & {
96
97
  __transportsUser?: Record<string, StreamableHTTPServerTransport>;
97
98
  __sessionUsers?: Record<string, string>;
98
99
  __sigintRegistered?: boolean;
100
+ __apiGcInterval?: ReturnType<typeof setInterval>;
99
101
  __runId?: string;
100
102
  };
101
103
 
104
+ const API_GC_INTERVAL_MS = 5 * 60 * 1000;
105
+
106
+ type GcCapableGlobal = typeof globalThis & { gc?: () => void };
107
+
108
+ function scheduleApiGc(reason: string): boolean {
109
+ const gc = (globalThis as GcCapableGlobal).gc;
110
+ if (typeof gc !== "function") return false;
111
+
112
+ const timer = setTimeout(() => {
113
+ const startedAt = Date.now();
114
+ try {
115
+ gc();
116
+ console.log(`[HTTP] Explicit GC completed after ${reason} in ${Date.now() - startedAt}ms`);
117
+ } catch (err) {
118
+ console.warn(`[HTTP] Explicit GC failed after ${reason}: ${err}`);
119
+ }
120
+ }, 0);
121
+ timer.unref?.();
122
+ return true;
123
+ }
124
+
125
+ function startApiGcInterval() {
126
+ if (globalState.__apiGcInterval) return;
127
+
128
+ const gc = (globalThis as GcCapableGlobal).gc;
129
+ if (typeof gc !== "function") {
130
+ console.log("[HTTP] Explicit GC unavailable; start API with --expose-gc to enable sweeps");
131
+ return;
132
+ }
133
+
134
+ const interval = setInterval(() => {
135
+ scheduleApiGc("periodic API sweep");
136
+ }, API_GC_INTERVAL_MS);
137
+ interval.unref?.();
138
+ globalState.__apiGcInterval = interval;
139
+ }
140
+
102
141
  // Clean up previous server on hot reload
103
142
  if (globalState.__httpServer) {
104
143
  console.log("[HTTP] Hot reload detected, closing previous server...");
@@ -234,6 +273,7 @@ const httpServer = createHttpServer(async (req, res) => {
234
273
  () => handleRepos(req, res, pathSegments, queryParams),
235
274
  () => handleSkills(req, res, pathSegments, queryParams, myAgentId),
236
275
  () => handleScripts(req, res, pathSegments, queryParams, myAgentId),
276
+ () => handleMcpBridge(req, res, pathSegments, queryParams, myAgentId),
237
277
  () => handleMcpServers(req, res, pathSegments, queryParams),
238
278
  () => handleMcpOAuth(req, res, pathSegments, queryParams),
239
279
  () => handleMemory(req, res, pathSegments, myAgentId),
@@ -315,6 +355,11 @@ async function shutdown() {
315
355
  // Stop MCP OAuth pending-session garbage collector
316
356
  stopMcpOAuthPendingGc();
317
357
 
358
+ if (globalState.__apiGcInterval) {
359
+ clearInterval(globalState.__apiGcInterval);
360
+ delete globalState.__apiGcInterval;
361
+ }
362
+
318
363
  // Close all active transports (SSE connections, etc.)
319
364
  for (const [id, transport] of Object.entries(transports)) {
320
365
  console.log(`[HTTP] Closing transport ${id}`);
@@ -349,6 +394,8 @@ if (!globalState.__runId) {
349
394
  globalState.__runId = `run_${Date.now()}`;
350
395
  }
351
396
 
397
+ startApiGcInterval();
398
+
352
399
  // Load global swarm configs before the server starts listening so decrypt/key
353
400
  // failures fail closed instead of leaving the runtime half-initialized.
354
401
  let startupConfigsInjected: string[] = [];
@@ -88,7 +88,12 @@ function resolveConfigValue(key: string): string | null {
88
88
  }
89
89
 
90
90
  function resolveMcpBaseUrl(): string {
91
- const configured = resolveConfigValue("MCP_BASE_URL");
91
+ // Browser-facing connect URLs: prefer the public ingress origin
92
+ // (PUBLIC_MCP_BASE_URL) over the internal MCP_BASE_URL so split deployments
93
+ // (Helm) surface a host the user's browser can actually reach. Both honor the
94
+ // swarm_config → env resolution order. Falls back to the localhost dev base.
95
+ const configured =
96
+ resolveConfigValue("PUBLIC_MCP_BASE_URL") ?? resolveConfigValue("MCP_BASE_URL");
92
97
  const fallback = `http://localhost:${process.env.PORT || "3013"}`;
93
98
  return (configured || fallback).replace(/\/+$/, "");
94
99
  }
@@ -0,0 +1,117 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { z } from "zod";
4
+ import { createServer } from "@/server";
5
+ import { isSdkToolAllowed } from "../scripts-runtime/sdk-allowlist";
6
+ import { route } from "./route-def";
7
+ import { json, jsonError } from "./utils";
8
+
9
+ // Lazy singleton — created once on first bridge call to avoid boot-time cost.
10
+ let _bridgeServer: McpServer | null = null;
11
+ function getBridgeServer(): McpServer {
12
+ if (!_bridgeServer) {
13
+ _bridgeServer = createServer();
14
+ }
15
+ return _bridgeServer;
16
+ }
17
+
18
+ type RegisteredTool = {
19
+ handler: Function;
20
+ inputSchema?: unknown;
21
+ enabled?: boolean;
22
+ };
23
+
24
+ type ToolRegistry = Record<string, RegisteredTool>;
25
+
26
+ const mcpBridgeRoute = route({
27
+ method: "post",
28
+ path: "/api/mcp-bridge",
29
+ pattern: ["api", "mcp-bridge"],
30
+ summary: "Generic MCP tool proxy for the scripts SDK bridge",
31
+ tags: ["Scripts"],
32
+ body: z.object({
33
+ tool: z.string().min(1).max(200),
34
+ args: z.record(z.string(), z.unknown()).default({}),
35
+ }),
36
+ responses: {
37
+ 200: { description: "Tool result" },
38
+ 400: { description: "Invalid tool name or args" },
39
+ 403: { description: "Tool not in SDK allowlist" },
40
+ 404: { description: "Tool not found" },
41
+ },
42
+ });
43
+
44
+ export async function handleMcpBridge(
45
+ req: IncomingMessage,
46
+ res: ServerResponse,
47
+ pathSegments: string[],
48
+ _queryParams?: URLSearchParams,
49
+ myAgentId?: string,
50
+ ): Promise<boolean> {
51
+ if (!mcpBridgeRoute.match(req.method, pathSegments)) return false;
52
+
53
+ const parsed = await mcpBridgeRoute.parse(req, res, pathSegments, new URLSearchParams());
54
+ if (!parsed) return true;
55
+
56
+ const { tool: toolName, args } = parsed.body;
57
+
58
+ if (!isSdkToolAllowed(toolName)) {
59
+ jsonError(res, `Tool '${toolName}' is not in the SDK allowlist`, 403);
60
+ return true;
61
+ }
62
+
63
+ const server = getBridgeServer();
64
+ const tools = (server as unknown as { _registeredTools: ToolRegistry })._registeredTools;
65
+
66
+ const tool = tools[toolName];
67
+ if (!tool) {
68
+ jsonError(res, `Tool '${toolName}' not found in the MCP registry`, 404);
69
+ return true;
70
+ }
71
+
72
+ if (tool.enabled === false) {
73
+ jsonError(res, `Tool '${toolName}' is disabled`, 400);
74
+ return true;
75
+ }
76
+
77
+ const sourceTaskId = Array.isArray(req.headers["x-source-task-id"])
78
+ ? req.headers["x-source-task-id"][0]
79
+ : (req.headers["x-source-task-id"] as string | undefined);
80
+
81
+ const extra = {
82
+ sessionId: "mcp-bridge",
83
+ requestInfo: {
84
+ headers: {
85
+ "x-agent-id": myAgentId ?? "",
86
+ ...(sourceTaskId ? { "x-source-task-id": sourceTaskId } : {}),
87
+ },
88
+ },
89
+ };
90
+
91
+ try {
92
+ const result = tool.inputSchema
93
+ ? await Promise.resolve(tool.handler(args, extra))
94
+ : await Promise.resolve(tool.handler(extra));
95
+
96
+ if (result && typeof result === "object" && "structuredContent" in result) {
97
+ json(res, result.structuredContent);
98
+ } else if (result && typeof result === "object" && "content" in result) {
99
+ const content = (result as { content: Array<{ type: string; text?: string }> }).content;
100
+ const text = content
101
+ .filter((c) => c.type === "text" && c.text)
102
+ .map((c) => c.text)
103
+ .join("\n");
104
+ try {
105
+ json(res, JSON.parse(text));
106
+ } catch {
107
+ json(res, { result: text });
108
+ }
109
+ } else {
110
+ json(res, result ?? {});
111
+ }
112
+ } catch (err) {
113
+ const message = err instanceof Error ? err.message : String(err);
114
+ jsonError(res, message, 500);
115
+ }
116
+ return true;
117
+ }
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import { getMcpServerById } from "../be/db";
4
+ import type { McpOAuthToken } from "../be/db-queries/mcp-oauth";
4
5
  import {
5
6
  applyMcpOAuthRefresh,
6
7
  consumeMcpOAuthPending,
@@ -23,6 +24,7 @@ import {
23
24
  registerClient,
24
25
  revokeMcpToken,
25
26
  } from "../oauth/mcp-wrapper";
27
+ import { getAppUrl, getPublicMcpBaseUrl } from "../utils/constants";
26
28
  import { route } from "./route-def";
27
29
  import { json, jsonError } from "./utils";
28
30
 
@@ -36,12 +38,14 @@ function ssrfOptions() {
36
38
  }
37
39
 
38
40
  function callbackRedirectUri(): string {
39
- const base = process.env.APP_URL?.replace(/\/+$/, "") ?? "http://localhost:3013";
40
- return `${base}/api/mcp-oauth/callback`;
41
+ // The callback route lives on the API server, so it must use the PUBLIC MCP
42
+ // base (externally reachable), not the dashboard APP_URL.
43
+ return `${getPublicMcpBaseUrl()}/api/mcp-oauth/callback`;
41
44
  }
42
45
 
43
46
  function dashboardBase(): string {
44
- return process.env.DASHBOARD_URL?.replace(/\/+$/, "") ?? "http://localhost:5274";
47
+ // getAppUrl absorbs DASHBOARD_URL as a deprecated alias.
48
+ return getAppUrl();
45
49
  }
46
50
 
47
51
  function defaultFinalRedirect(mcpServerId: string): string {
@@ -61,6 +65,43 @@ interface DiscoveryResult {
61
65
  bearerMethodsSupported: string[] | null;
62
66
  }
63
67
 
68
+ interface OAuthClientForAuthorize {
69
+ clientId: string;
70
+ clientSecret: string | null;
71
+ resourceUrl: string;
72
+ authorizationServerIssuer: string;
73
+ authorizeUrl: string;
74
+ tokenUrl: string;
75
+ revocationUrl: string | null;
76
+ scopes: string[];
77
+ }
78
+
79
+ function splitScopes(scopes: string | null | undefined): string[] {
80
+ return scopes?.split(/\s+/).filter(Boolean) ?? [];
81
+ }
82
+
83
+ function manualClientFromToken(token: McpOAuthToken | null): OAuthClientForAuthorize | null {
84
+ if (!token || token.clientSource !== "manual" || !token.dcrClientId) return null;
85
+
86
+ // The manual-client route validates these on write. Re-check before using the
87
+ // stored endpoints because /authorize redirects the browser to authorizeUrl.
88
+ assertUrlSafe(token.resourceUrl, ssrfOptions());
89
+ assertUrlSafe(token.authorizeUrl, ssrfOptions());
90
+ assertUrlSafe(token.tokenUrl, ssrfOptions());
91
+ if (token.revocationUrl) assertUrlSafe(token.revocationUrl, ssrfOptions());
92
+
93
+ return {
94
+ clientId: token.dcrClientId,
95
+ clientSecret: token.dcrClientSecret,
96
+ resourceUrl: token.resourceUrl,
97
+ authorizationServerIssuer: token.authorizationServerIssuer,
98
+ authorizeUrl: token.authorizeUrl,
99
+ tokenUrl: token.tokenUrl,
100
+ revocationUrl: token.revocationUrl,
101
+ scopes: splitScopes(token.scope),
102
+ };
103
+ }
104
+
64
105
  async function discoverForMcp(resourceUrl: string): Promise<DiscoveryResult | null> {
65
106
  assertUrlSafe(resourceUrl, ssrfOptions());
66
107
 
@@ -266,10 +307,10 @@ interface AuthorizeFlowQuery {
266
307
  }
267
308
 
268
309
  /**
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.
310
+ * Use a stored manual client or discover metadata + DCR-register, build the
311
+ * authorize URL, and persist the pending session. Returns the provider
312
+ * `providerUrl` the caller should redirect to / respond with. On failure,
313
+ * writes a JSON error response and returns null.
273
314
  */
274
315
  async function prepareAuthorizeFlow(
275
316
  res: ServerResponse,
@@ -277,15 +318,26 @@ async function prepareAuthorizeFlow(
277
318
  server: NonNullable<ReturnType<typeof getMcpServerById>>,
278
319
  q: AuthorizeFlowQuery,
279
320
  ): 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
- }
321
+ const userId = q.userId ?? null;
322
+ let client = manualClientFromToken(getMcpOAuthToken(mcpServerId, userId));
323
+
324
+ if (!client) {
325
+ const discovery = await discoverForMcp(server.url!);
326
+ if (!discovery) {
327
+ jsonError(res, "MCP server does not require OAuth", 400);
328
+ return null;
329
+ }
285
330
 
286
- let clientId: string | null = null;
287
- let clientSecret: string | null = null;
288
- if (discovery.dcrSupported && discovery.registrationEndpoint) {
331
+ if (!discovery.dcrSupported || !discovery.registrationEndpoint) {
332
+ jsonError(
333
+ res,
334
+ "DCR not supported — paste client_id/client_secret via POST /api/mcp-oauth/:id/manual-client first.",
335
+ 400,
336
+ );
337
+ return null;
338
+ }
339
+
340
+ const scopes = q.scopes ? splitScopes(q.scopes) : discovery.scopes;
289
341
  const dcr = await registerClient(discovery.registrationEndpoint, {
290
342
  client_name: `agent-swarm (${server.name})`,
291
343
  redirect_uris: [callbackRedirectUri()],
@@ -293,43 +345,45 @@ async function prepareAuthorizeFlow(
293
345
  response_types: ["code"],
294
346
  token_endpoint_auth_method: "client_secret_basic",
295
347
  application_type: "web",
296
- scope: (q.scopes ?? discovery.scopes.join(" ")) || undefined,
348
+ scope: scopes.join(" ") || undefined,
297
349
  });
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;
350
+
351
+ client = {
352
+ clientId: dcr.client_id,
353
+ clientSecret: dcr.client_secret ?? null,
354
+ resourceUrl: discovery.resourceUrl,
355
+ authorizationServerIssuer: discovery.authorizationServerIssuer,
356
+ authorizeUrl: discovery.authorizeUrl,
357
+ tokenUrl: discovery.tokenUrl,
358
+ revocationUrl: discovery.revocationUrl,
359
+ scopes,
360
+ };
307
361
  }
308
362
 
309
- const scopes = q.scopes ? q.scopes.split(" ").filter(Boolean) : discovery.scopes;
363
+ const scopes = q.scopes ? splitScopes(q.scopes) : client.scopes;
310
364
 
311
365
  const built = await buildAuthorizeUrl({
312
- authorizeUrl: discovery.authorizeUrl,
313
- tokenUrl: discovery.tokenUrl,
314
- clientId: clientId!,
366
+ authorizeUrl: client.authorizeUrl,
367
+ tokenUrl: client.tokenUrl,
368
+ clientId: client.clientId,
315
369
  redirectUri: callbackRedirectUri(),
316
370
  scopes,
317
- resource: discovery.resourceUrl,
371
+ resource: client.resourceUrl,
318
372
  });
319
373
 
320
374
  insertMcpOAuthPending({
321
375
  state: built.state,
322
376
  mcpServerId,
323
- userId: q.userId ?? null,
377
+ userId,
324
378
  codeVerifier: built.codeVerifier,
325
- resourceUrl: discovery.resourceUrl,
326
- authorizationServerIssuer: discovery.authorizationServerIssuer,
327
- authorizeUrl: discovery.authorizeUrl,
328
- tokenUrl: discovery.tokenUrl,
329
- revocationUrl: discovery.revocationUrl,
379
+ resourceUrl: client.resourceUrl,
380
+ authorizationServerIssuer: client.authorizationServerIssuer,
381
+ authorizeUrl: client.authorizeUrl,
382
+ tokenUrl: client.tokenUrl,
383
+ revocationUrl: client.revocationUrl,
330
384
  scopes: scopes.join(" "),
331
- dcrClientId: clientId!,
332
- dcrClientSecret: clientSecret,
385
+ dcrClientId: client.clientId,
386
+ dcrClientSecret: client.clientSecret,
333
387
  redirectUri: callbackRedirectUri(),
334
388
  finalRedirect: q.redirect ?? null,
335
389
  });
@@ -390,6 +444,10 @@ export async function handleMcpOAuth(
390
444
  codeVerifier: pending.codeVerifier,
391
445
  resource: pending.resourceUrl,
392
446
  });
447
+ const existing = getMcpOAuthToken(pending.mcpServerId, pending.userId);
448
+ const clientSource =
449
+ existing?.clientSource ??
450
+ (pending.dcrClientId ? ("dcr" as const) : ("preregistered" as const));
393
451
 
394
452
  upsertMcpOAuthToken({
395
453
  mcpServerId: pending.mcpServerId,
@@ -406,7 +464,7 @@ export async function handleMcpOAuth(
406
464
  revocationUrl: pending.revocationUrl,
407
465
  dcrClientId: pending.dcrClientId,
408
466
  dcrClientSecret: pending.dcrClientSecret,
409
- clientSource: pending.dcrClientId ? "dcr" : "preregistered",
467
+ clientSource,
410
468
  lastRefreshedAt: new Date().toISOString(),
411
469
  });
412
470
 
@@ -54,6 +54,8 @@ const searchMemory = route({
54
54
  body: z.object({
55
55
  query: z.string().min(1),
56
56
  limit: z.number().int().min(1).max(20).default(5),
57
+ scope: z.enum(["agent", "swarm", "all"]).default("all"),
58
+ source: z.enum(["manual", "file_index", "session_summary", "task_completion"]).optional(),
57
59
  }),
58
60
  responses: {
59
61
  200: { description: "Search results" },
@@ -325,7 +327,7 @@ export async function handleMemory(
325
327
  const parsed = await searchMemory.parse(req, res, pathSegments, new URLSearchParams());
326
328
  if (!parsed) return true;
327
329
 
328
- const { query, limit } = parsed.body;
330
+ const { query, limit, scope, source } = parsed.body;
329
331
 
330
332
  try {
331
333
  const provider = getEmbeddingProvider();
@@ -339,8 +341,9 @@ export async function handleMemory(
339
341
 
340
342
  const candidateLimit = Math.min(limit, 20) * CANDIDATE_SET_MULTIPLIER;
341
343
  const candidates = store.search(queryEmbedding, myAgentId, {
342
- scope: "all",
344
+ scope,
343
345
  limit: candidateLimit,
346
+ source,
344
347
  isLead: false,
345
348
  });
346
349
  const ranked = rerank(candidates, { limit: Math.min(limit, 20) });
@@ -4,6 +4,7 @@ import {
4
4
  OpenApiGeneratorV31,
5
5
  } from "@asteasolutions/zod-to-openapi";
6
6
  import { z } from "zod";
7
+ import { getPublicMcpBaseUrl } from "../utils/constants";
7
8
  import { routeRegistry } from "./route-def";
8
9
 
9
10
  extendZodWithOpenApi(z);
@@ -74,8 +75,7 @@ export function generateOpenApiSpec(opts: OpenApiOptions): string {
74
75
  });
75
76
  }
76
77
 
77
- const serverUrl =
78
- opts.serverUrl || process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
78
+ const serverUrl = opts.serverUrl || getPublicMcpBaseUrl();
79
79
 
80
80
  const generator = new OpenApiGeneratorV31(registry.definitions);
81
81
  const doc = generator.generateDocument({
@@ -25,6 +25,7 @@ import { z } from "zod";
25
25
  import { BROWSER_SDK_JS, SWARM_UI_JS } from "../artifact-sdk/browser-sdk";
26
26
  import { getPage, incrementPageViewCount } from "../be/db";
27
27
  import type { Page } from "../types";
28
+ import { getAppUrl, getConfiguredAppUrls } from "../utils/constants";
28
29
  import { extractAndVerifyCookie, issuePageSessionCookie } from "../utils/page-session";
29
30
  import { scrubSecrets } from "../utils/secret-scrubber";
30
31
  import { route } from "./route-def";
@@ -159,14 +160,15 @@ function stripJsonSuffix(idSegment: string): string | null {
159
160
  }
160
161
 
161
162
  /**
162
- * Compute the SPA base URL (`APP_URL`). Mirrors `getAppBaseUrl` in pages.ts
163
- * duplicated here to keep this module standalone (no cross-import inside the
164
- * http/ layer).
163
+ * Compute the SPA base URL. Public JSON pages historically redirect to the
164
+ * local dashboard when no app URL is configured; keep that route-local
165
+ * fallback while still delegating `APP_URL`/`DASHBOARD_URL` resolution to the
166
+ * shared helper.
165
167
  */
168
+ const LOCAL_PAGE_APP_URL = "http://localhost:5274";
169
+
166
170
  function getAppBaseUrl(): string {
167
- const env = process.env.APP_URL?.trim();
168
- if (env) return env.replace(/\/+$/, "");
169
- return "http://localhost:5274";
171
+ return getAppUrl(LOCAL_PAGE_APP_URL);
170
172
  }
171
173
 
172
174
  /**
@@ -177,15 +179,12 @@ function getAppBaseUrl(): string {
177
179
  */
178
180
  function buildCsp(): string {
179
181
  // `frame-ancestors` lists every origin allowed to iframe `/p/:id`. We must
180
- // include the SPA origin(s). `APP_URL` may carry a comma-separated list so
182
+ // include the SPA origin(s). Configured app URLs may be comma-separated so
181
183
  // portless dev (`https://ui.swarm.localhost`), a Vite port (`http://localhost:5274`),
182
184
  // and a tunnel/staging origin can all coexist. Additionally, in non-production
183
185
  // we always allow `http://localhost:*` and `https://*.localhost` so swapping
184
186
  // between Vite ports / portless dev doesn't require restarting the API.
185
- const configured = (process.env.APP_URL ?? "")
186
- .split(",")
187
- .map((s) => s.trim().replace(/\/+$/, ""))
188
- .filter(Boolean);
187
+ const configured = getConfiguredAppUrls();
189
188
  const devFallbacks =
190
189
  process.env.NODE_ENV === "production"
191
190
  ? []
package/src/http/pages.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from "../be/db";
15
15
  import { snapshotPage } from "../pages/version";
16
16
  import { type Page, PageAuthModeSchema, PageContentTypeSchema, type PageSummary } from "../types";
17
+ import { getAppUrl, getPublicMcpBaseUrl } from "../utils/constants";
17
18
  import { issuePageSessionCookie } from "../utils/page-session";
18
19
  import { route } from "./route-def";
19
20
  import { BODY_TOO_LARGE, enforceContentLengthCap, json, jsonError } from "./utils";
@@ -280,25 +281,20 @@ function applyLaunchCors(req: IncomingMessage, res: ServerResponse): void {
280
281
 
281
282
  /**
282
283
  * Resolve the public API base URL used to build a page's `api_url` share
283
- * pointer. Falls back to `http://localhost:<PORT>` when `MCP_BASE_URL` is
284
- * unset (same convention as src/tools/memory-rate.ts, etc.). Trailing slashes
285
- * are stripped so callers can concatenate `/p/:id` directly.
284
+ * pointer (handed to a browser). Delegates to the shared
285
+ * {@link getPublicMcpBaseUrl} helper (trailing slashes already stripped) so
286
+ * callers can concatenate `/p/:id` directly.
286
287
  */
287
288
  function getApiBaseUrl(): string {
288
- const env = process.env.MCP_BASE_URL?.trim();
289
- if (env) return env.replace(/\/+$/, "");
290
- return `http://localhost:${process.env.PORT || "3013"}`;
289
+ return getPublicMcpBaseUrl();
291
290
  }
292
291
 
293
292
  /**
294
293
  * Resolve the SPA / dashboard base URL used to build a page's `app_url` share
295
- * pointer (→ `/pages/:id`). `APP_URL` is the canonical env (matches the
296
- * request-human-input tool); falls back to the local dev port `5274`.
294
+ * pointer (→ `/pages/:id`). Delegates to the shared {@link getAppUrl} helper.
297
295
  */
298
296
  function getAppBaseUrl(): string {
299
- const env = process.env.APP_URL?.trim();
300
- if (env) return env.replace(/\/+$/, "");
301
- return "http://localhost:5274";
297
+ return getAppUrl();
302
298
  }
303
299
 
304
300
  /** Decorate a page row with share-URL pointers. */
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
- import { getAgentById } from "../be/db";
3
+ import { getAgentById, upsertKv } from "../be/db";
4
4
  import { createEvent } from "../be/events";
5
5
  import { deleteScript, getScript, upsertScriptByName } from "../be/scripts/db";
6
6
  import { searchScripts } from "../be/scripts/embeddings";
@@ -37,6 +37,7 @@ const runBodySchema = z
37
37
  intent: z.string().default(""),
38
38
  scope: ScriptScopeSchema.optional(),
39
39
  fsMode: ScriptFsModeSchema.default("none"),
40
+ idempotencyKey: z.string().max(200).optional(),
40
41
  })
41
42
  .refine((body) => Boolean(body.name) !== Boolean(body.source), {
42
43
  message: "Provide exactly one of name or source",
@@ -289,6 +290,27 @@ export async function handleScripts(
289
290
  agentId: agent.id,
290
291
  });
291
292
 
293
+ // Persist output to KV when idempotencyKey is provided and run succeeded
294
+ let kvSaved: { namespace: string; key: string } | undefined;
295
+ if (parsed.body.idempotencyKey && !output.error && output.exitCode === 0) {
296
+ const kvNamespace = `script:executions`;
297
+ const kvKey = parsed.body.idempotencyKey;
298
+ const kvValue = {
299
+ result: output.result,
300
+ durationMs: output.durationMs,
301
+ scriptName: parsed.body.name ?? null,
302
+ executedAt: new Date().toISOString(),
303
+ };
304
+ upsertKv({
305
+ namespace: kvNamespace,
306
+ key: kvKey,
307
+ value: kvValue,
308
+ valueType: "json",
309
+ expiresAt: null,
310
+ });
311
+ kvSaved = { namespace: kvNamespace, key: kvKey };
312
+ }
313
+
292
314
  let autoSaved: { slug: string; reason: string } | undefined;
293
315
  if (parsed.body.source && !output.error && output.exitCode === 0) {
294
316
  const slug = scratchSlug(parsed.body.intent, parsed.body.source);
@@ -314,6 +336,7 @@ export async function handleScripts(
314
336
  scrubObject({
315
337
  result: output.result,
316
338
  autoSaved,
339
+ kvSaved,
317
340
  truncated: output.truncated,
318
341
  durationMs: output.durationMs,
319
342
  stdout: output.stdout,
package/src/http/tasks.ts CHANGED
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { ensure } from "@desplega.ai/business-use";
3
3
  import { z } from "zod";
4
4
  import {
5
+ backfillSupersedeTaskResumeTaskId,
5
6
  cancelTask,
6
7
  completeTask,
7
8
  failTask,
@@ -905,6 +906,7 @@ export async function handleTasks(
905
906
  }
906
907
 
907
908
  const resumeTaskId = followUp.task.id;
909
+ backfillSupersedeTaskResumeTaskId(parsed.params.id, resumeTaskId);
908
910
 
909
911
  ensure({
910
912
  id: "task.superseded",
package/src/http/utils.ts CHANGED
@@ -157,14 +157,21 @@ export function triggerSchemaErrorResponse(
157
157
  * redirect URIs). Returns a URL with no trailing slash.
158
158
  *
159
159
  * Resolution order:
160
- * 1. `MCP_BASE_URL` env (canonical)
161
- * 2. Inbound request host `X-Forwarded-Proto`/`X-Forwarded-Host` if behind
160
+ * 1. `PUBLIC_MCP_BASE_URL` env — explicit public origin. Wins so split
161
+ * deployments (Helm) can keep `MCP_BASE_URL` pointed at an internal
162
+ * cluster address while outbound URLs use the public ingress.
163
+ * 2. `MCP_BASE_URL` env — canonical when public and internal hosts coincide
164
+ * (e.g. an ngrok tunnel set as `MCP_BASE_URL` in local dev).
165
+ * 3. Inbound request host — `X-Forwarded-Proto`/`X-Forwarded-Host` if behind
162
166
  * a proxy/tunnel (ngrok), else `Host` header. Lets the URL stay correct
163
- * when MCP_BASE_URL is unset and the API is reached via an arbitrary
167
+ * when neither env var is set and the API is reached via an arbitrary
164
168
  * external hostname.
165
- * 3. `http://localhost:<PORT>` fallback
169
+ * 4. `http://localhost:<PORT>` fallback
166
170
  */
167
171
  export function deriveApiBaseUrl(req: IncomingMessage): string {
172
+ const publicBase = process.env.PUBLIC_MCP_BASE_URL?.trim();
173
+ if (publicBase) return publicBase.replace(/\/+$/, "");
174
+
168
175
  const envBase = process.env.MCP_BASE_URL?.trim();
169
176
  if (envBase) return envBase.replace(/\/+$/, "");
170
177