@desplega.ai/agent-swarm 1.83.1 → 1.84.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 (69) hide show
  1. package/openapi.json +158 -8
  2. package/package.json +1 -1
  3. package/src/artifact-sdk/server.ts +23 -1
  4. package/src/be/budget-admission.ts +28 -4
  5. package/src/be/budget-refusal-notify.ts +19 -3
  6. package/src/be/db-queries/oauth.ts +43 -0
  7. package/src/be/db.ts +35 -2
  8. package/src/be/migrations/074_user_budget_scope.sql +85 -0
  9. package/src/commands/resume-session.ts +118 -0
  10. package/src/commands/runner.ts +137 -67
  11. package/src/http/core.ts +4 -1
  12. package/src/http/index.ts +16 -0
  13. package/src/http/integrations.ts +26 -0
  14. package/src/http/mcp-user.ts +111 -0
  15. package/src/http/poll.ts +19 -5
  16. package/src/http/schedules.ts +1 -1
  17. package/src/http/users.ts +107 -2
  18. package/src/http/webhooks.ts +101 -0
  19. package/src/integrations/kapso/client.ts +198 -0
  20. package/src/integrations/kapso/config.ts +104 -0
  21. package/src/integrations/kapso/inbound.ts +111 -0
  22. package/src/jira/client.ts +3 -5
  23. package/src/jira/oauth.ts +1 -0
  24. package/src/jira/sync.ts +2 -2
  25. package/src/oauth/ensure-token.ts +1 -0
  26. package/src/oauth/wrapper.ts +38 -7
  27. package/src/providers/claude-adapter.ts +7 -2
  28. package/src/providers/claude-managed-adapter.ts +1 -1
  29. package/src/providers/codex-adapter.ts +30 -0
  30. package/src/providers/opencode-adapter.ts +149 -14
  31. package/src/providers/pi-mono-adapter.ts +41 -1
  32. package/src/providers/types.ts +1 -1
  33. package/src/server-user.ts +117 -0
  34. package/src/server.ts +14 -0
  35. package/src/tests/artifact-sdk.test.ts +23 -19
  36. package/src/tests/budget-user-scope.test.ts +376 -0
  37. package/src/tests/claude-managed-adapter.test.ts +6 -0
  38. package/src/tests/codex-adapter.test.ts +192 -0
  39. package/src/tests/codex-rate-limit-parse.test.ts +256 -0
  40. package/src/tests/db-queries-oauth.test.ts +43 -0
  41. package/src/tests/ensure-token.test.ts +93 -0
  42. package/src/tests/error-tracker.test.ts +52 -0
  43. package/src/tests/fetch-resolved-env.test.ts +33 -20
  44. package/src/tests/http-users.test.ts +29 -1
  45. package/src/tests/kapso-client.test.ts +94 -0
  46. package/src/tests/kapso-inbound.test.ts +198 -0
  47. package/src/tests/mcp-user-route.test.ts +325 -0
  48. package/src/tests/opencode-adapter.test.ts +75 -0
  49. package/src/tests/pi-mono-adapter.test.ts +21 -1
  50. package/src/tests/rate-limit-event.test.ts +69 -6
  51. package/src/tests/resume-session.test.ts +93 -0
  52. package/src/tests/task-tools-ctx.test.ts +100 -0
  53. package/src/tests/task-tools-ownership.test.ts +167 -0
  54. package/src/tests/tool-annotations.test.ts +3 -2
  55. package/src/tests/user-token-routes.test.ts +221 -0
  56. package/src/tools/cancel-task.ts +137 -83
  57. package/src/tools/get-task-details.ts +73 -59
  58. package/src/tools/get-tasks.ts +134 -126
  59. package/src/tools/register-kapso-number.ts +210 -0
  60. package/src/tools/send-task.ts +312 -312
  61. package/src/tools/task-action.ts +464 -367
  62. package/src/tools/task-tool-ctx.ts +43 -0
  63. package/src/tools/templates.ts +35 -0
  64. package/src/tools/tool-config.ts +6 -0
  65. package/src/tools/whatsapp-message.ts +135 -0
  66. package/src/types.ts +6 -2
  67. package/src/utils/error-tracker.ts +122 -9
  68. package/templates/skills/agentmail-sending/SKILL.md +49 -0
  69. package/templates/skills/kapso-whatsapp/SKILL.md +383 -0
@@ -0,0 +1,118 @@
1
+ import type { ProviderName } from "../types";
2
+
3
+ export type ResumeSessionSource = "task" | "parent";
4
+
5
+ export interface ResumeSessionCandidate {
6
+ source: ResumeSessionSource;
7
+ sessionId?: string | null;
8
+ taskId?: string;
9
+ provider?: ProviderName;
10
+ providerMeta?: Record<string, unknown>;
11
+ }
12
+
13
+ export interface ResumeSessionSkip {
14
+ source: ResumeSessionSource;
15
+ sessionId: string;
16
+ provider?: ProviderName;
17
+ reason: string;
18
+ }
19
+
20
+ export interface ResumeSessionResolution {
21
+ resumeSessionId?: string;
22
+ source?: ResumeSessionSource;
23
+ provider?: ProviderName;
24
+ skipped: ResumeSessionSkip[];
25
+ }
26
+
27
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
+
29
+ const RESUMABLE_PROVIDERS = new Set<ProviderName>(["claude", "claude-managed", "codex"]);
30
+
31
+ export function isClaudeCliSessionId(sessionId: string): boolean {
32
+ return UUID_RE.test(sessionId);
33
+ }
34
+
35
+ function normalizeStoredProvider(candidate: ResumeSessionCandidate): ProviderName | undefined {
36
+ if (candidate.provider === "claude" && candidate.providerMeta?.managed === true) {
37
+ return "claude-managed";
38
+ }
39
+ return candidate.provider;
40
+ }
41
+
42
+ function providerSupportsResume(provider: ProviderName): boolean {
43
+ return RESUMABLE_PROVIDERS.has(provider);
44
+ }
45
+
46
+ export function resolveResumeSession(
47
+ currentProvider: ProviderName,
48
+ candidates: ResumeSessionCandidate[],
49
+ ): ResumeSessionResolution {
50
+ const skipped: ResumeSessionSkip[] = [];
51
+
52
+ for (const candidate of candidates) {
53
+ const sessionId = candidate.sessionId?.trim();
54
+ if (!sessionId) continue;
55
+
56
+ const storedProvider = normalizeStoredProvider(candidate);
57
+
58
+ if (!storedProvider) {
59
+ if (currentProvider === "claude" && isClaudeCliSessionId(sessionId)) {
60
+ return {
61
+ resumeSessionId: sessionId,
62
+ source: candidate.source,
63
+ provider: "claude",
64
+ skipped,
65
+ };
66
+ }
67
+
68
+ skipped.push({
69
+ source: candidate.source,
70
+ sessionId,
71
+ reason:
72
+ currentProvider === "claude"
73
+ ? "legacy Claude resume requires a UUID session id"
74
+ : "stored session provider is unknown",
75
+ });
76
+ continue;
77
+ }
78
+
79
+ if (storedProvider !== currentProvider) {
80
+ skipped.push({
81
+ source: candidate.source,
82
+ sessionId,
83
+ provider: storedProvider,
84
+ reason: `stored session provider ${storedProvider} does not match current provider ${currentProvider}`,
85
+ });
86
+ continue;
87
+ }
88
+
89
+ if (!providerSupportsResume(currentProvider)) {
90
+ skipped.push({
91
+ source: candidate.source,
92
+ sessionId,
93
+ provider: storedProvider,
94
+ reason: `provider ${currentProvider} does not support runner resume`,
95
+ });
96
+ continue;
97
+ }
98
+
99
+ if (currentProvider === "claude" && !isClaudeCliSessionId(sessionId)) {
100
+ skipped.push({
101
+ source: candidate.source,
102
+ sessionId,
103
+ provider: storedProvider,
104
+ reason: "Claude CLI --resume requires a UUID session id",
105
+ });
106
+ continue;
107
+ }
108
+
109
+ return {
110
+ resumeSessionId: sessionId,
111
+ source: candidate.source,
112
+ provider: storedProvider,
113
+ skipped,
114
+ };
115
+ }
116
+
117
+ return { skipped };
118
+ }
@@ -38,7 +38,11 @@ import { getApiKey } from "../utils/api-key.ts";
38
38
  import { computeBudgetBackoffMs } from "../utils/budget-backoff.ts";
39
39
  import { getContextWindowSize } from "../utils/context-window.ts";
40
40
  import { type CredentialSelection, resolveCredentialPools } from "../utils/credentials.ts";
41
- import { parseRateLimitResetTime } from "../utils/error-tracker.ts";
41
+ import {
42
+ isRateLimitMessage,
43
+ MAX_RATE_LIMIT_RESET_MS,
44
+ parseRateLimitResetTime,
45
+ } from "../utils/error-tracker.ts";
42
46
  import { resolveHarnessProvider } from "../utils/harness-provider.ts";
43
47
  import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
44
48
  import { scrubSecrets } from "../utils/secret-scrubber.ts";
@@ -53,6 +57,11 @@ import {
53
57
  reportCredStatus,
54
58
  reportLatestModel,
55
59
  } from "./provider-credentials.ts";
60
+ import {
61
+ type ResumeSessionCandidate,
62
+ type ResumeSessionResolution,
63
+ resolveResumeSession,
64
+ } from "./resume-session.ts";
56
65
  // Side-effect import: registers runner trigger/resumption templates
57
66
  import "./templates.ts";
58
67
 
@@ -990,6 +999,8 @@ async function getPausedTasksFromAPI(config: ApiConfig): Promise<
990
999
  task: string;
991
1000
  progress?: string;
992
1001
  claudeSessionId?: string;
1002
+ provider?: ProviderName;
1003
+ providerMeta?: Record<string, unknown>;
993
1004
  parentTaskId?: string;
994
1005
  dir?: string;
995
1006
  vcsRepo?: string;
@@ -1022,6 +1033,8 @@ async function getPausedTasksFromAPI(config: ApiConfig): Promise<
1022
1033
  task: string;
1023
1034
  progress?: string;
1024
1035
  claudeSessionId?: string;
1036
+ provider?: ProviderName;
1037
+ providerMeta?: Record<string, unknown>;
1025
1038
  parentTaskId?: string;
1026
1039
  dir?: string;
1027
1040
  vcsRepo?: string;
@@ -1424,24 +1437,51 @@ async function saveProviderSessionIdOnActiveSession(
1424
1437
  });
1425
1438
  }
1426
1439
 
1427
- /** Fetch Claude session ID for a task (for --resume) */
1428
- async function fetchProviderSessionId(
1440
+ interface ProviderSessionInfo {
1441
+ sessionId: string | null;
1442
+ provider?: ProviderName;
1443
+ providerMeta?: Record<string, unknown>;
1444
+ }
1445
+
1446
+ /** Fetch provider session metadata for a task (for resume continuity). */
1447
+ async function fetchProviderSessionInfo(
1429
1448
  apiUrl: string,
1430
1449
  apiKey: string,
1431
1450
  taskId: string,
1432
- ): Promise<string | null> {
1451
+ ): Promise<ProviderSessionInfo | null> {
1433
1452
  const headers: Record<string, string> = {};
1434
1453
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
1435
1454
  try {
1436
1455
  const response = await fetch(`${apiUrl}/api/tasks/${taskId}`, { headers });
1437
1456
  if (!response.ok) return null;
1438
- const data = (await response.json()) as { claudeSessionId?: string };
1439
- return data.claudeSessionId || null;
1457
+ const data = (await response.json()) as {
1458
+ claudeSessionId?: string;
1459
+ provider?: ProviderName;
1460
+ providerMeta?: Record<string, unknown>;
1461
+ };
1462
+ return {
1463
+ sessionId: data.claudeSessionId || null,
1464
+ provider: data.provider,
1465
+ providerMeta: data.providerMeta,
1466
+ };
1440
1467
  } catch {
1441
1468
  return null;
1442
1469
  }
1443
1470
  }
1444
1471
 
1472
+ function logResumeResolution(role: string, resolution: ResumeSessionResolution): void {
1473
+ for (const skipped of resolution.skipped) {
1474
+ console.warn(
1475
+ `[${role}] Skipping ${skipped.source} session resume ${skipped.sessionId.slice(0, 8)}: ${skipped.reason}`,
1476
+ );
1477
+ }
1478
+
1479
+ if (resolution.resumeSessionId) {
1480
+ const source = resolution.source === "parent" ? "parent session" : "task's own session";
1481
+ console.log(`[${role}] Resuming ${source} ${resolution.resumeSessionId.slice(0, 8)}`);
1482
+ }
1483
+ }
1484
+
1445
1485
  /** Register an active session with the API (fire-and-forget) */
1446
1486
  async function registerActiveSession(
1447
1487
  config: ApiConfig,
@@ -2102,6 +2142,7 @@ async function spawnProviderProcess(
2102
2142
  iteration: number;
2103
2143
  taskId?: string;
2104
2144
  model?: string;
2145
+ resumeSessionId?: string;
2105
2146
  harnessProvider: ProviderName;
2106
2147
  cwd?: string;
2107
2148
  vcsRepo?: string;
@@ -2167,6 +2208,7 @@ async function spawnProviderProcess(
2167
2208
  vcsRepo: opts.vcsRepo,
2168
2209
  logFile: opts.logFile,
2169
2210
  additionalArgs: opts.additionalArgs,
2211
+ resumeSessionId: opts.resumeSessionId,
2170
2212
  iteration: opts.iteration,
2171
2213
  env: freshEnv as Record<string, string>,
2172
2214
  // Propagate the selected OAuth slot so the adapter refreshes back to the
@@ -2813,54 +2855,61 @@ async function checkCompletedProcesses(
2813
2855
  if (result.exitCode !== 0 && result.failureReason) {
2814
2856
  failureReason = result.failureReason;
2815
2857
  console.log(`[${role}] Detected error for task ${taskId.slice(0, 8)}: ${failureReason}`);
2858
+ }
2816
2859
 
2817
- // If rate-limited and we know which key was used, report it.
2818
- // Codex adapter prefixes failure reasons with `[rate-limit]` /
2819
- // `[usage-limit]` (see codex-adapter.formatTerminalError); Claude
2820
- // surfaces "rate limit" / "hit your limit" via SessionErrorTracker.
2821
- if (
2822
- credentialInfo &&
2823
- /rate.?limit|hit your limit|usage[ _-]?limit|too many requests/i.test(failureReason)
2824
- ) {
2825
- // Three-tier reset-time resolver (most to least precise):
2826
- // Tier 1: structured rate_limit_event from Claude CLI (resetsAt epoch sec)
2827
- // Tier 2: regex on the error message (e.g. "resets 3pm (UTC)")
2828
- // Tier 3: 5-min hard fallback — only when both structured and regex fail
2829
- // Tiers 1 & 2 are clamped to [now+60s, now+6h] at their source.
2830
- const clampResetTime = (isoString: string): string => {
2831
- const nowMs = Date.now();
2832
- const minMs = nowMs + 60_000;
2833
- const maxMs = nowMs + 6 * 60 * 60 * 1000;
2834
- const candidateMs = new Date(isoString).getTime();
2835
- return new Date(Math.min(Math.max(candidateMs, minMs), maxMs)).toISOString();
2836
- };
2860
+ // If rate-limited and we know which key was used, report it.
2861
+ // Codex adapter prefixes failure reasons with `[rate-limit]` /
2862
+ // `[usage-limit]` (see codex-adapter.formatTerminalError); Claude
2863
+ // surfaces "rate limit" / "hit your limit" via SessionErrorTracker.
2864
+ //
2865
+ // The gate must also fire on a bare structured rate_limit_event: a
2866
+ // `status: "rejected"` event sets result.rateLimitResetAt but does NOT
2867
+ // set hasErrors(), so failureReason can be empty even though the key is
2868
+ // exhausted. Gating on rateLimitResetAt != null ensures the structured
2869
+ // event alone still triggers the cooldown.
2870
+ if (
2871
+ credentialInfo &&
2872
+ (result.rateLimitResetAt != null ||
2873
+ (failureReason != null && isRateLimitMessage(failureReason)))
2874
+ ) {
2875
+ // Three-tier reset-time resolver (most to least precise):
2876
+ // Tier 1: structured rate_limit_event from Claude CLI (resetsAt epoch sec)
2877
+ // Tier 2: regex on the error message (e.g. "resets 3pm (UTC)")
2878
+ // Tier 3: 5-min hard fallback — only when both structured and regex fail
2879
+ // Tiers 1 & 2 are clamped to [now+60s, now+7d] (weekly limits reset ~2 days out).
2880
+ const clampResetTime = (isoString: string): string => {
2881
+ const nowMs = Date.now();
2882
+ const minMs = nowMs + 60_000;
2883
+ const maxMs = nowMs + MAX_RATE_LIMIT_RESET_MS;
2884
+ const candidateMs = new Date(isoString).getTime();
2885
+ return new Date(Math.min(Math.max(candidateMs, minMs), maxMs)).toISOString();
2886
+ };
2837
2887
 
2838
- let rateLimitedUntil: string;
2839
- if (result.rateLimitResetAt) {
2840
- rateLimitedUntil = clampResetTime(result.rateLimitResetAt);
2888
+ let rateLimitedUntil: string;
2889
+ if (result.rateLimitResetAt) {
2890
+ rateLimitedUntil = clampResetTime(result.rateLimitResetAt);
2891
+ console.log(`[credentials] Rate limit reset from rate_limit_event: ${rateLimitedUntil}`);
2892
+ } else if (failureReason != null) {
2893
+ const parsedResetTime = parseRateLimitResetTime(failureReason);
2894
+ if (parsedResetTime) {
2895
+ rateLimitedUntil = clampResetTime(parsedResetTime);
2841
2896
  console.log(
2842
- `[credentials] Rate limit reset from rate_limit_event: ${rateLimitedUntil}`,
2897
+ `[credentials] Parsed rate limit reset time from error: ${rateLimitedUntil}`,
2843
2898
  );
2844
2899
  } else {
2845
- const parsedResetTime = parseRateLimitResetTime(failureReason);
2846
- if (parsedResetTime) {
2847
- rateLimitedUntil = clampResetTime(parsedResetTime);
2848
- console.log(
2849
- `[credentials] Parsed rate limit reset time from error: ${rateLimitedUntil}`,
2850
- );
2851
- } else {
2852
- rateLimitedUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString();
2853
- }
2900
+ rateLimitedUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString();
2854
2901
  }
2855
- reportKeyRateLimit(
2856
- apiConfig.apiUrl,
2857
- apiConfig.apiKey,
2858
- credentialInfo.keyType,
2859
- credentialInfo.keySuffix,
2860
- credentialInfo.keyIndex,
2861
- rateLimitedUntil,
2862
- ).catch(() => {});
2902
+ } else {
2903
+ rateLimitedUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString();
2863
2904
  }
2905
+ reportKeyRateLimit(
2906
+ apiConfig.apiUrl,
2907
+ apiConfig.apiKey,
2908
+ credentialInfo.keyType,
2909
+ credentialInfo.keySuffix,
2910
+ credentialInfo.keyIndex,
2911
+ rateLimitedUntil,
2912
+ ).catch(() => {});
2864
2913
  }
2865
2914
  await ensureTaskFinished(
2866
2915
  apiConfig,
@@ -3703,18 +3752,30 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3703
3752
  console.log(`[${role}] Injected relevant memories into resumed task prompt`);
3704
3753
  }
3705
3754
 
3706
- // Resolve --resume: prefer own session ID, then parent's
3707
- let resumeAdditionalArgs = opts.additionalArgs || [];
3708
- if (task.claudeSessionId) {
3709
- resumeAdditionalArgs = [...resumeAdditionalArgs, "--resume", task.claudeSessionId];
3710
- console.log(`[${role}] Resuming task's own session ${task.claudeSessionId.slice(0, 8)}`);
3711
- } else if (task.parentTaskId) {
3712
- const parentSessionId = await fetchProviderSessionId(apiUrl, apiKey, task.parentTaskId);
3713
- if (parentSessionId) {
3714
- resumeAdditionalArgs = [...resumeAdditionalArgs, "--resume", parentSessionId];
3715
- console.log(`[${role}] Resuming parent session ${parentSessionId.slice(0, 8)}`);
3755
+ // Resolve provider-aware resume: prefer own session, then parent.
3756
+ const resumeCandidates: ResumeSessionCandidate[] = [
3757
+ {
3758
+ source: "task",
3759
+ taskId: task.id,
3760
+ sessionId: task.claudeSessionId,
3761
+ provider: task.provider,
3762
+ providerMeta: task.providerMeta,
3763
+ },
3764
+ ];
3765
+ if (task.parentTaskId) {
3766
+ const parentSession = await fetchProviderSessionInfo(apiUrl, apiKey, task.parentTaskId);
3767
+ if (parentSession?.sessionId) {
3768
+ resumeCandidates.push({
3769
+ source: "parent",
3770
+ taskId: task.parentTaskId,
3771
+ sessionId: parentSession.sessionId,
3772
+ provider: parentSession.provider,
3773
+ providerMeta: parentSession.providerMeta,
3774
+ });
3716
3775
  }
3717
3776
  }
3777
+ const resumeResolution = resolveResumeSession(state.harnessProvider, resumeCandidates);
3778
+ logResumeResolution(role, resumeResolution);
3718
3779
 
3719
3780
  // Spawn Claude process for resumed task
3720
3781
  iteration++;
@@ -3780,7 +3841,8 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3780
3841
  prompt: resumePrompt,
3781
3842
  logFile,
3782
3843
  systemPrompt: resolvedSystemPrompt,
3783
- additionalArgs: resumeAdditionalArgs,
3844
+ additionalArgs: opts.additionalArgs,
3845
+ resumeSessionId: resumeResolution.resumeSessionId,
3784
3846
  role,
3785
3847
  apiUrl,
3786
3848
  apiKey,
@@ -4045,20 +4107,27 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4045
4107
  }
4046
4108
  }
4047
4109
 
4048
- // Resolve --resume for child tasks with parentTaskId
4049
- let effectiveAdditionalArgs = opts.additionalArgs || [];
4110
+ // Resolve provider-aware resume for child tasks with parentTaskId.
4111
+ let resumeSessionId: string | undefined;
4050
4112
  const taskObj = trigger.task as { parentTaskId?: string } | undefined;
4051
4113
  if (taskObj?.parentTaskId) {
4052
- const parentSessionId = await fetchProviderSessionId(
4114
+ const parentSession = await fetchProviderSessionInfo(
4053
4115
  apiUrl,
4054
4116
  apiKey,
4055
4117
  taskObj.parentTaskId,
4056
4118
  );
4057
- if (parentSessionId) {
4058
- effectiveAdditionalArgs = [...effectiveAdditionalArgs, "--resume", parentSessionId];
4059
- console.log(
4060
- `[${role}] Child task — resuming parent session ${parentSessionId.slice(0, 8)}`,
4061
- );
4119
+ if (parentSession?.sessionId) {
4120
+ const resumeResolution = resolveResumeSession(state.harnessProvider, [
4121
+ {
4122
+ source: "parent",
4123
+ taskId: taskObj.parentTaskId,
4124
+ sessionId: parentSession.sessionId,
4125
+ provider: parentSession.provider,
4126
+ providerMeta: parentSession.providerMeta,
4127
+ },
4128
+ ]);
4129
+ logResumeResolution(role, resumeResolution);
4130
+ resumeSessionId = resumeResolution.resumeSessionId;
4062
4131
  } else {
4063
4132
  console.log(`[${role}] Child task — parent session ID not found, starting fresh`);
4064
4133
  }
@@ -4197,7 +4266,8 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4197
4266
  prompt: triggerPrompt,
4198
4267
  logFile,
4199
4268
  systemPrompt: taskSystemPrompt,
4200
- additionalArgs: effectiveAdditionalArgs,
4269
+ additionalArgs: opts.additionalArgs,
4270
+ resumeSessionId,
4201
4271
  role,
4202
4272
  apiUrl,
4203
4273
  apiKey,
package/src/http/core.ts CHANGED
@@ -240,7 +240,10 @@ export async function handleCore(
240
240
  // fall through to the bearer check (fail-closed).
241
241
  if (apiKey) {
242
242
  const pathSegments = getPathSegments(req.url || "");
243
- if (!isPublicRoute(req.method, pathSegments)) {
243
+ const isUserMcpRoute = req.url === "/mcp-user";
244
+ // `/mcp-user` runs its own `aswt_`-token auth in `handleMcpUser`; the swarm
245
+ // API key must not gate it.
246
+ if (!isUserMcpRoute && !isPublicRoute(req.method, pathSegments)) {
244
247
  const authHeader = req.headers.authorization;
245
248
  const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
246
249
 
package/src/http/index.ts CHANGED
@@ -43,6 +43,7 @@ import { handleKv } from "./kv";
43
43
  import { handleMcp } from "./mcp";
44
44
  import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from "./mcp-oauth";
45
45
  import { handleMcpServers } from "./mcp-servers";
46
+ import { handleMcpUser } from "./mcp-user";
46
47
  import { handleMemory } from "./memory";
47
48
  import { handlePageProxy } from "./page-proxy";
48
49
  import { handlePages } from "./pages";
@@ -89,6 +90,8 @@ const apiKey = getApiKey();
89
90
  const globalState = globalThis as typeof globalThis & {
90
91
  __httpServer?: Server<typeof IncomingMessage, typeof ServerResponse>;
91
92
  __transports?: Record<string, StreamableHTTPServerTransport>;
93
+ __transportsUser?: Record<string, StreamableHTTPServerTransport>;
94
+ __sessionUsers?: Record<string, string>;
92
95
  __sigintRegistered?: boolean;
93
96
  __runId?: string;
94
97
  };
@@ -100,6 +103,9 @@ if (globalState.__httpServer) {
100
103
  }
101
104
 
102
105
  const transports: Record<string, StreamableHTTPServerTransport> = globalState.__transports ?? {};
106
+ const transportsUser: Record<string, StreamableHTTPServerTransport> =
107
+ globalState.__transportsUser ?? {};
108
+ const sessionUsers: Record<string, string> = globalState.__sessionUsers ?? {};
103
109
 
104
110
  const httpServer = createHttpServer(async (req, res) => {
105
111
  const startTime = performance.now();
@@ -234,6 +240,7 @@ const httpServer = createHttpServer(async (req, res) => {
234
240
  () => handleInboxState(req, res, pathSegments, queryParams),
235
241
  () => handleTaskTemplates(req, res, pathSegments, queryParams),
236
242
  () => handleMcp(req, res, transports),
243
+ () => handleMcpUser(req, res, transportsUser, sessionUsers),
237
244
  ];
238
245
 
239
246
  try {
@@ -271,6 +278,8 @@ const httpServer = createHttpServer(async (req, res) => {
271
278
  // Store references in globalThis for hot reload persistence
272
279
  globalState.__httpServer = httpServer;
273
280
  globalState.__transports = transports;
281
+ globalState.__transportsUser = transportsUser;
282
+ globalState.__sessionUsers = sessionUsers;
274
283
 
275
284
  async function shutdown() {
276
285
  console.log("Shutting down HTTP server...");
@@ -303,6 +312,13 @@ async function shutdown() {
303
312
  delete transports[id];
304
313
  }
305
314
 
315
+ for (const [id, transport] of Object.entries(transportsUser)) {
316
+ console.log(`[HTTP] Closing user transport ${id}`);
317
+ transport.close();
318
+ delete transportsUser[id];
319
+ delete sessionUsers[id];
320
+ }
321
+
306
322
  // Close all active connections forcefully
307
323
  httpServer.closeAllConnections();
308
324
  httpServer.close(() => {
@@ -45,6 +45,20 @@ const claudeManagedTestRoute = route({
45
45
  },
46
46
  });
47
47
 
48
+ const mcpUserConfigRoute = route({
49
+ method: "get",
50
+ path: "/api/integrations/mcp-user/config",
51
+ pattern: ["api", "integrations", "mcp-user", "config"],
52
+ summary: "Get server-derived config for end-user MCP clients.",
53
+ tags: ["Integrations"],
54
+ responses: {
55
+ 200: {
56
+ description:
57
+ "Server-derived MCP user config. `mcpBaseUrl` is the API server base URL and `mcpUserUrl` appends `/mcp-user`.",
58
+ },
59
+ },
60
+ });
61
+
48
62
  // ─── Helpers ─────────────────────────────────────────────────────────────────
49
63
 
50
64
  /**
@@ -73,6 +87,12 @@ function resolveConfigValue(key: string): string | null {
73
87
  return null;
74
88
  }
75
89
 
90
+ function resolveMcpBaseUrl(): string {
91
+ const configured = resolveConfigValue("MCP_BASE_URL");
92
+ const fallback = `http://localhost:${process.env.PORT || "3013"}`;
93
+ return (configured || fallback).replace(/\/+$/, "");
94
+ }
95
+
76
96
  // ─── Public handler factory ──────────────────────────────────────────────────
77
97
 
78
98
  /**
@@ -89,6 +109,12 @@ export function createIntegrationsHandler(deps: TestConnectionDeps = {}) {
89
109
  res: ServerResponse,
90
110
  pathSegments: string[],
91
111
  ): Promise<boolean> {
112
+ if (mcpUserConfigRoute.match(req.method, pathSegments)) {
113
+ const mcpBaseUrl = resolveMcpBaseUrl();
114
+ json(res, { mcpBaseUrl, mcpUserUrl: `${mcpBaseUrl}/mcp-user` });
115
+ return true;
116
+ }
117
+
92
118
  if (claudeManagedTestRoute.match(req.method, pathSegments)) {
93
119
  const apiKey = resolveConfigValue("ANTHROPIC_API_KEY");
94
120
  const agentId = resolveConfigValue("MANAGED_AGENT_ID");
@@ -0,0 +1,111 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5
+ import { resolveUserByToken } from "@/be/users";
6
+ import { createUserServer } from "@/server-user";
7
+ import type { User } from "@/types";
8
+
9
+ function unauthorized(res: ServerResponse): true {
10
+ res.writeHead(401, { "Content-Type": "application/json" });
11
+ res.end(JSON.stringify({ error: "Unauthorized" }));
12
+ return true;
13
+ }
14
+
15
+ function extractBearer(req: IncomingMessage): string | null {
16
+ const header = req.headers.authorization;
17
+ if (!header?.startsWith("Bearer ")) return null;
18
+ const token = header.slice("Bearer ".length).trim();
19
+ return token.startsWith("aswt_") ? token : null;
20
+ }
21
+
22
+ function resolveActiveUser(req: IncomingMessage): User | null {
23
+ const token = extractBearer(req);
24
+ if (!token) return null;
25
+ const user = resolveUserByToken(token);
26
+ if (!user || user.status !== "active") return null;
27
+ return user;
28
+ }
29
+
30
+ export async function handleMcpUser(
31
+ req: IncomingMessage,
32
+ res: ServerResponse,
33
+ transports: Record<string, StreamableHTTPServerTransport>,
34
+ sessionUsers: Record<string, string>,
35
+ ): Promise<boolean> {
36
+ const sessionId = req.headers["mcp-session-id"] as string | undefined;
37
+
38
+ if (req.url !== "/mcp-user") {
39
+ return false;
40
+ }
41
+
42
+ const user = resolveActiveUser(req);
43
+ if (!user) return unauthorized(res);
44
+
45
+ if (sessionId && transports[sessionId] && sessionUsers[sessionId] !== user.id) {
46
+ return unauthorized(res);
47
+ }
48
+
49
+ if (req.method === "POST") {
50
+ const chunks: Buffer[] = [];
51
+ for await (const chunk of req) {
52
+ chunks.push(chunk);
53
+ }
54
+ const body = JSON.parse(Buffer.concat(chunks).toString());
55
+
56
+ let transport: StreamableHTTPServerTransport;
57
+
58
+ if (sessionId && transports[sessionId]) {
59
+ transport = transports[sessionId];
60
+ } else if (!sessionId && isInitializeRequest(body)) {
61
+ transport = new StreamableHTTPServerTransport({
62
+ sessionIdGenerator: () => randomUUID(),
63
+ onsessioninitialized: (id) => {
64
+ transports[id] = transport;
65
+ sessionUsers[id] = user.id;
66
+ },
67
+ onsessionclosed: (id) => {
68
+ delete transports[id];
69
+ delete sessionUsers[id];
70
+ },
71
+ });
72
+
73
+ transport.onclose = () => {
74
+ if (transport.sessionId) {
75
+ delete transports[transport.sessionId];
76
+ delete sessionUsers[transport.sessionId];
77
+ }
78
+ };
79
+
80
+ const server = createUserServer(user);
81
+ await server.connect(transport);
82
+ } else {
83
+ res.writeHead(400, { "Content-Type": "application/json" });
84
+ res.end(
85
+ JSON.stringify({
86
+ jsonrpc: "2.0",
87
+ error: { code: -32000, message: "Invalid session" },
88
+ id: null,
89
+ }),
90
+ );
91
+ return true;
92
+ }
93
+
94
+ await transport.handleRequest(req, res, body);
95
+ return true;
96
+ }
97
+
98
+ if (req.method === "GET" || req.method === "DELETE") {
99
+ if (sessionId && transports[sessionId]) {
100
+ await transports[sessionId].handleRequest(req, res);
101
+ return true;
102
+ }
103
+ res.writeHead(400);
104
+ res.end("Invalid session");
105
+ return true;
106
+ }
107
+
108
+ res.writeHead(405);
109
+ res.end("Method not allowed");
110
+ return true;
111
+ }