@desplega.ai/agent-swarm 1.80.2 → 1.81.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 (40) hide show
  1. package/README.md +3 -0
  2. package/openapi.json +486 -29
  3. package/package.json +3 -3
  4. package/plugin/commands/user-management.md +85 -46
  5. package/plugin/pi-skills/user-management/SKILL.md +85 -46
  6. package/src/agentmail/handlers.ts +25 -3
  7. package/src/agentmail/types.ts +1 -0
  8. package/src/be/db.ts +33 -109
  9. package/src/be/migrations/067_users_first_class.sql +185 -0
  10. package/src/be/migrations/068_profile_changed_event_type.sql +56 -0
  11. package/src/be/unmapped-identities.ts +98 -0
  12. package/src/be/users.ts +531 -0
  13. package/src/github/handlers.ts +67 -7
  14. package/src/gitlab/handlers.ts +73 -5
  15. package/src/http/operator-actor.ts +59 -0
  16. package/src/http/users.ts +611 -21
  17. package/src/http/webhooks.ts +9 -0
  18. package/src/http/workflows.ts +2 -15
  19. package/src/linear/oauth.ts +61 -1
  20. package/src/linear/sync.ts +134 -21
  21. package/src/slack/actions.ts +8 -2
  22. package/src/slack/assistant.ts +12 -9
  23. package/src/slack/enrich.ts +162 -0
  24. package/src/slack/handlers.ts +11 -19
  25. package/src/tests/agentmail-handlers.test.ts +166 -0
  26. package/src/tests/github-handlers.test.ts +290 -0
  27. package/src/tests/gitlab-handlers.test.ts +293 -1
  28. package/src/tests/http-api-integration.test.ts +8 -4
  29. package/src/tests/http-users.test.ts +605 -0
  30. package/src/tests/linear-sync-identity.test.ts +427 -0
  31. package/src/tests/mcp-tools-user.test.ts +292 -0
  32. package/src/tests/slack-identity-resolution.test.ts +349 -0
  33. package/src/tests/user-identity.test.ts +351 -81
  34. package/src/tests/workflow-triggers-v2.test.ts +261 -20
  35. package/src/tools/manage-user.ts +119 -24
  36. package/src/tools/resolve-user.ts +43 -29
  37. package/src/types.ts +26 -4
  38. package/src/utils/secret-scrubber.ts +5 -0
  39. package/src/workflows/input.ts +7 -2
  40. package/src/workflows/triggers.ts +89 -9
@@ -8,12 +8,14 @@
8
8
  * - Detects bot mentions
9
9
  */
10
10
 
11
- import { failTask, findTaskByVcs, getAllAgents, resolveUser } from "../be/db";
11
+ import { failTask, findTaskByVcs, getAllAgents, incrKv, upsertKv } from "../be/db";
12
+ import { findOrCreateUserByEmail, findUserByExternalId, linkIdentity } from "../be/users";
12
13
  import { resolveTemplate } from "../prompts/resolver";
13
14
  import { gitlabContextKey } from "../tasks/context-key";
14
15
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
15
16
  import { GITLAB_BOT_NAME } from "./auth";
16
17
  import { addGitLabNoteReaction, addGitLabReaction } from "./reactions";
18
+ import type { GitLabUser } from "./types";
17
19
  // Side-effect import: registers all GitLab event templates in the in-memory registry
18
20
  import "./templates";
19
21
  import type { IssueEvent, MergeRequestEvent, NoteEvent, PipelineEvent } from "./types";
@@ -53,6 +55,62 @@ function findLeadAgent() {
53
55
  );
54
56
  }
55
57
 
58
+ // ── Identity resolution ──
59
+
60
+ const UNMAPPED_NAMESPACE = "integration:unmapped:gitlab";
61
+ const UNMAPPED_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
62
+ const GITLAB_WEBHOOK_ACTOR = { kind: "system", id: "webhook:gitlab" } as const;
63
+
64
+ /**
65
+ * Resolve a GitLab webhook sender to a `users.id`.
66
+ *
67
+ * Cascade (Q17):
68
+ * 1. Fast path — `findUserByExternalId('gitlab', user.username)`.
69
+ * 2. If missing AND `user.email` is a real non-empty string (some GitLab
70
+ * installations send `email: ""` instead of omitting the field — Q17.E
71
+ * manual spot-check), run `findOrCreateUserByEmail` + `linkIdentity`.
72
+ * 3. Otherwise record an unmapped tracker entry (kv) for operator triage.
73
+ *
74
+ * Returns `undefined` when no mapping could be established — callers pass
75
+ * that straight to `requestedByUserId`.
76
+ */
77
+ function resolveGitLabSender(
78
+ user: GitLabUser,
79
+ sampleEventType: string,
80
+ sampleContext: string,
81
+ ): string | undefined {
82
+ const existing = findUserByExternalId("gitlab", user.username);
83
+ if (existing) return existing.id;
84
+
85
+ // Inline-email cascade — only run when email is a real non-empty string.
86
+ // Some GitLab installations emit `email: ""` instead of omitting the field.
87
+ const inlineEmail = typeof user.email === "string" ? user.email.trim() : "";
88
+ if (inlineEmail !== "") {
89
+ const { user: linked } = findOrCreateUserByEmail(
90
+ inlineEmail,
91
+ { name: user.name },
92
+ GITLAB_WEBHOOK_ACTOR,
93
+ );
94
+ linkIdentity(linked.id, "gitlab", user.username, GITLAB_WEBHOOK_ACTOR);
95
+ return linked.id;
96
+ }
97
+
98
+ // No mapping + no inline email → unmapped tracker.
99
+ upsertKv({
100
+ namespace: UNMAPPED_NAMESPACE,
101
+ key: `${user.username}:meta`,
102
+ value: {
103
+ lastSeenAt: new Date().toISOString(),
104
+ sampleEventType,
105
+ sampleContext: sampleContext.slice(0, 100),
106
+ },
107
+ valueType: "json",
108
+ expiresAt: Date.now() + UNMAPPED_TTL_MS,
109
+ });
110
+ incrKv(UNMAPPED_NAMESPACE, `${user.username}:count`, 1);
111
+ return undefined;
112
+ }
113
+
56
114
  // ── Event Handlers ──
57
115
 
58
116
  export async function handleMergeRequest(
@@ -63,7 +121,11 @@ export async function handleMergeRequest(
63
121
  const repo = project.path_with_namespace;
64
122
 
65
123
  // Resolve canonical user from GitLab sender
66
- const requestedByUserId = resolveUser({ gitlabUsername: user.username })?.id;
124
+ const requestedByUserId = resolveGitLabSender(
125
+ user,
126
+ "merge_request",
127
+ `MR !${mr.iid}: ${mr.title}`,
128
+ );
67
129
 
68
130
  console.log(`[GitLab] MR #${mr.iid} ${action} by ${user.username} in ${repo}`);
69
131
 
@@ -163,7 +225,11 @@ export async function handleIssue(
163
225
  const repo = project.path_with_namespace;
164
226
 
165
227
  // Resolve canonical user from GitLab sender
166
- const requestedByUserId = resolveUser({ gitlabUsername: user.username })?.id;
228
+ const requestedByUserId = resolveGitLabSender(
229
+ user,
230
+ "issue",
231
+ `Issue #${issue.iid}: ${issue.title}`,
232
+ );
167
233
 
168
234
  console.log(`[GitLab] Issue #${issue.iid} ${action} by ${user.username} in ${repo}`);
169
235
 
@@ -246,8 +312,10 @@ export async function handleNote(event: NoteEvent): Promise<{ created: boolean;
246
312
  const { user, project, object_attributes: note } = event;
247
313
  const repo = project.path_with_namespace;
248
314
 
249
- // Resolve canonical user from GitLab sender
250
- const _requestedByUserId = resolveUser({ gitlabUsername: user.username })?.id;
315
+ // Resolve canonical user from GitLab sender — currently dead-coded
316
+ // (underscore prefix). Rewired for parity with live sites; if a future
317
+ // change uses the value, the resolution path is already correct.
318
+ const _requestedByUserId = resolveGitLabSender(user, "note", note.note);
251
319
 
252
320
  // Only handle comments with bot mentions
253
321
  if (!detectMention(note.note)) {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Operator-auth middleware producing the `op:<sha256(rawKey)[:16]>` fingerprint
3
+ * (Q16) that is embedded as `actor` in `user_identity_events` rows.
4
+ *
5
+ * The bearer-key check already gates every `route({ auth: { apiKey: true } })`
6
+ * route via `src/http/core.ts::handleCore` — so by the time we reach the route
7
+ * handler we know the caller's `Authorization: Bearer <key>` matches the
8
+ * configured swarm key. This helper just re-reads that key, hashes it with
9
+ * `fingerprintApiKey`, and packages it as an `IdentityActor` for the identity
10
+ * mutation helpers in `src/be/users.ts`.
11
+ *
12
+ * MUST read the swarm key via `getApiKey()` from `src/utils/api-key.ts` —
13
+ * direct `process.env.{API_KEY,AGENT_SWARM_API_KEY}` reads are rejected by
14
+ * `scripts/check-api-key-boundary.sh` (CI).
15
+ */
16
+
17
+ import type { IncomingMessage, ServerResponse } from "node:http";
18
+ import { fingerprintApiKey, type IdentityActor } from "../be/users";
19
+ import { getApiKey } from "../utils/api-key";
20
+ import { jsonError } from "./utils";
21
+
22
+ /**
23
+ * Extract the raw bearer key from the request. Returns null if the header is
24
+ * missing or malformed.
25
+ */
26
+ function extractBearer(req: IncomingMessage): string | null {
27
+ const raw = req.headers.authorization;
28
+ const header = Array.isArray(raw) ? raw[0] : raw;
29
+ if (!header || !header.startsWith("Bearer ")) return null;
30
+ return header.slice(7);
31
+ }
32
+
33
+ /**
34
+ * Resolve the calling operator's `IdentityActor`. Assumes `handleCore` already
35
+ * 401'd unauthenticated requests; on the off chance the header is missing here
36
+ * (e.g. a route mistakenly opted out of api-key gating), we respond with 401
37
+ * and return null so the caller can short-circuit.
38
+ *
39
+ * Returns null after writing a 401 response. The caller MUST stop processing
40
+ * the request when this returns null.
41
+ */
42
+ export function getOperatorActor(req: IncomingMessage, res: ServerResponse): IdentityActor | null {
43
+ const rawKey = extractBearer(req);
44
+ const swarmKey = getApiKey();
45
+
46
+ // If no key is configured server-side, the public-route guard already
47
+ // skipped the bearer check. We still need *some* fingerprint for the audit
48
+ // event — fall back to a stable placeholder so callers see consistent shapes.
49
+ if (!swarmKey) {
50
+ return { kind: "operator", id: fingerprintApiKey("") };
51
+ }
52
+
53
+ if (!rawKey || rawKey !== swarmKey) {
54
+ jsonError(res, "Unauthorized", 401);
55
+ return null;
56
+ }
57
+
58
+ return { kind: "operator", id: fingerprintApiKey(rawKey) };
59
+ }