@desplega.ai/agent-swarm 2.5.0 → 2.5.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.
Files changed (160) hide show
  1. package/dist/{acp-adapter-11gp67nr.js → acp-adapter-csh0zz3b.js} +1 -1
  2. package/dist/{actions-8p2kze8j.js → actions-6xx079d1.js} +17 -17
  3. package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-8n6f0hz8.js} +2 -2
  4. package/dist/{app-0qp0a2gk.js → app-ft6r6v23.js} +3 -3
  5. package/dist/{assistant-jedd3ktm.js → assistant-vt2cyfzd.js} +18 -18
  6. package/dist/{boot-reembed-n4wq5dd2.js → boot-reembed-r3e808ky.js} +1 -1
  7. package/dist/{boot-reembed-4xwq5tg0.js → boot-reembed-tgrfy27t.js} +1 -1
  8. package/dist/{boot-scrub-sweep-69h90xcq.js → boot-scrub-sweep-masdwxkm.js} +2 -2
  9. package/dist/{boot-seeds-3vn0m9sg.js → boot-seeds-0n82qc7q.js} +4 -4
  10. package/dist/{claude-adapter-221mvad5.js → claude-adapter-s7j47k06.js} +2 -2
  11. package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3hzrgrnk.js} +1 -1
  12. package/dist/{claude-sdk-session-8thjsk5e.js → claude-sdk-session-0zrdzyx2.js} +6905 -6855
  13. package/dist/{cli-y9jvxfhk.js → cli-0vgy5wfw.js} +2 -2
  14. package/dist/{cli-bgdtv62r.js → cli-14v1sxxy.js} +6 -6
  15. package/dist/{cli-fy1jyyym.js → cli-15xd0j7w.js} +18 -13
  16. package/dist/{cli-36j4jnk3.js → cli-3e9vs93g.js} +4 -4
  17. package/dist/{cli-5rvg1ddk.js → cli-4qkg9968.js} +3 -3
  18. package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
  19. package/dist/{cli-s4sntwt4.js → cli-6y43v79h.js} +1 -1
  20. package/dist/{cli-z63a269p.js → cli-7ccf7akh.js} +1 -1
  21. package/dist/{cli-0z3vjrpm.js → cli-86zpfje5.js} +116 -5
  22. package/dist/{cli-fes51ya2.js → cli-aaqnqpyw.js} +2 -2
  23. package/dist/{cli-mbfj11fq.js → cli-b1tv22wr.js} +1 -1
  24. package/dist/{cli-ftmq4nvk.js → cli-b63d08rs.js} +1 -1
  25. package/dist/{cli-xt768r5m.js → cli-b6tnjdvx.js} +11 -7
  26. package/dist/{cli-0y6f0ckq.js → cli-bpbcpd8b.js} +1 -1
  27. package/dist/{cli-sy8f9705.js → cli-cf63dnxj.js} +2 -2
  28. package/dist/{cli-fcc5qe3f.js → cli-csh8fnbg.js} +82 -16
  29. package/dist/{cli-x6vf4sad.js → cli-cyfgxnvb.js} +7 -7
  30. package/dist/{cli-9ce2xw98.js → cli-d172svcw.js} +5 -5
  31. package/dist/{cli-we0ra057.js → cli-e91vshb1.js} +41 -6
  32. package/dist/{cli-segs7hx9.js → cli-edtm741g.js} +5 -5
  33. package/dist/{cli-x21dqn18.js → cli-f3pfn4x3.js} +1 -1
  34. package/dist/{cli-nnvjb17s.js → cli-f55b6ky0.js} +1 -1
  35. package/dist/{cli-1pr672hz.js → cli-fgn7yg5r.js} +1 -1
  36. package/dist/{cli-5kzk3472.js → cli-h5d22pfs.js} +287 -78
  37. package/dist/{cli-r25qpaxt.js → cli-ha22h0eg.js} +20 -20
  38. package/dist/{cli-92mqenkc.js → cli-j3dwd4kz.js} +173 -134
  39. package/dist/{cli-j1eghqfm.js → cli-jmcjbjkt.js} +1 -1
  40. package/dist/{cli-179xcyky.js → cli-kcnwn85g.js} +4 -3
  41. package/dist/{cli-rh5smt8t.js → cli-mntxtkhn.js} +12 -2
  42. package/dist/{cli-7g40g8hq.js → cli-n2918sed.js} +9 -9
  43. package/dist/{cli-y4x8ww6s.js → cli-qrjv7t18.js} +1 -1
  44. package/dist/{cli-7bwtc6yx.js → cli-vaxw3bg0.js} +3 -3
  45. package/dist/{cli-4w24f7z3.js → cli-wadzapwb.js} +1 -1
  46. package/dist/{cli-jpxsrxcb.js → cli-xw9q173h.js} +1 -1
  47. package/dist/{cli-xawhpgc9.js → cli-xzge46sj.js} +2 -2
  48. package/dist/{cli-3zwye9sn.js → cli-y3dzwyy3.js} +3 -3
  49. package/dist/{cli-xd4eqtr2.js → cli-ydg6ep51.js} +1 -1
  50. package/dist/cli.js +16 -15
  51. package/dist/{codex-adapter-tcg1q3jk.js → codex-adapter-brh2tcxr.js} +1 -1
  52. package/dist/{codex-session-runner-xfx4wd9x.js → codex-session-runner-0dnj21qs.js} +1 -1
  53. package/dist/{commands-ew83nj70.js → commands-1cnvseqx.js} +2 -2
  54. package/dist/{controller-gnq1k1mf.js → controller-kc7s9xhn.js} +2 -2
  55. package/dist/{controllers-connect-tw1dan9r.js → controllers-connect-jzx7t4bn.js} +18 -18
  56. package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-qpwp5sfg.js} +1 -1
  57. package/dist/{db-t86xrnes.js → db-4vq88jkc.js} +3 -3
  58. package/dist/{db-7gbc53fd.js → db-v5ytg5pw.js} +1 -1
  59. package/dist/{dispatcher-64bdyhps.js → dispatcher-trtcrqae.js} +16 -16
  60. package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-f20xwn1z.js} +1 -1
  61. package/dist/{external-channel-e9nw24zv.js → external-channel-n9bymc2n.js} +2 -2
  62. package/dist/{grok-adapter-v82srhrx.js → grok-adapter-4kpmmnr6.js} +2 -2
  63. package/dist/{handlers-c9ajzhbn.js → handlers-ptsraqa8.js} +19 -19
  64. package/dist/{heartbeat-2vqs1319.js → heartbeat-60qtxtjf.js} +19 -19
  65. package/dist/{hook-01nk13wx.js → hook-ngtvh729.js} +1 -1
  66. package/dist/{hook-jzpheegg.js → hook-rbktcstg.js} +1 -1
  67. package/dist/{http-fc1k6ec8.js → http-z8xy72gp.js} +334 -86
  68. package/dist/{index-zdycv3ja.js → index-06qkmcx6.js} +10 -9
  69. package/dist/{index-07ef7nkm.js → index-1cscynkb.js} +16 -16
  70. package/dist/{index-sepja052.js → index-a9nsavhx.js} +1 -1
  71. package/dist/{index-q3t40ryv.js → index-r70q0xjx.js} +16 -16
  72. package/dist/{index-7yts8n9s.js → index-s193j5fh.js} +19 -19
  73. package/dist/{keepalive-5nh2rhmr.js → keepalive-a9srcphj.js} +3 -3
  74. package/dist/{lead-aayq64jn.js → lead-3devneqy.js} +26 -26
  75. package/dist/{maintenance-hjhzdt3k.js → maintenance-0er16h2y.js} +1 -1
  76. package/dist/{oauth-refresh-sweep-y3n1knmd.js → oauth-refresh-sweep-b65jcyw2.js} +2 -2
  77. package/dist/{onboard-1zq3m7db.js → onboard-wqywz5py.js} +2 -2
  78. package/dist/{otel-impl-h9yyza47.js → otel-impl-vk32s884.js} +1 -1
  79. package/dist/{pricing-refresh-nevhat8h.js → pricing-refresh-t27anv4z.js} +3 -3
  80. package/dist/rbac-roles-452665y0.js +26 -0
  81. package/dist/{rbac-roles-4y6ngvzq.js → rbac-roles-bmcgsxjn.js} +12 -2
  82. package/dist/{render-v2-cfsh13z3.js → render-v2-0ttsk2j4.js} +3 -3
  83. package/dist/{resume-5s2t0dbs.js → resume-a26kvr41.js} +5 -5
  84. package/dist/{session-access-dnbyp3cn.js → session-access-dgh9nkwk.js} +4 -4
  85. package/dist/{setup-1wj1y77j.js → setup-9vc9qahm.js} +1 -1
  86. package/dist/{work-objects-7mg6216g.js → work-objects-vn8nkxwn.js} +19 -19
  87. package/dist/{worker-nwb789mg.js → worker-1x1h8vnm.js} +26 -26
  88. package/openapi.json +463 -3
  89. package/package.json +4 -3
  90. package/src/be/db/session-tree.ts +89 -2
  91. package/src/be/db/session-visibility.ts +31 -0
  92. package/src/be/db/tasks/read.ts +18 -5
  93. package/src/be/db.ts +2 -0
  94. package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
  95. package/src/be/migrations/235_chat_relay_defer.sql +86 -0
  96. package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
  97. package/src/be/rbac-roles.ts +138 -0
  98. package/src/be/scripts/run-credential.ts +64 -0
  99. package/src/be/scripts/run-saved.ts +3 -2
  100. package/src/be/users.ts +28 -4
  101. package/src/gitlab/auth.ts +8 -2
  102. package/src/gitlab/handlers.ts +60 -12
  103. package/src/gitlab/index.ts +1 -1
  104. package/src/http/all-routes.ts +1 -0
  105. package/src/http/approval-requests.ts +46 -6
  106. package/src/http/index.ts +2 -0
  107. package/src/http/schedules.ts +26 -0
  108. package/src/http/scripts.ts +2 -2
  109. package/src/http/sessions.ts +93 -2
  110. package/src/http/user-roles.ts +173 -0
  111. package/src/http/users.ts +9 -0
  112. package/src/http/x.ts +2 -2
  113. package/src/integrations/chat-sdk/commands.ts +14 -1
  114. package/src/integrations/chat-sdk/inbound.ts +40 -5
  115. package/src/integrations/chat-sdk/outbound.ts +35 -10
  116. package/src/integrations/chat-sdk/reactions.ts +107 -0
  117. package/src/integrations/chat-sdk/relay.ts +75 -7
  118. package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
  119. package/src/integrations/reaction-events.ts +20 -0
  120. package/src/providers/amp-adapter.ts +1 -1
  121. package/src/rbac/legacy-policy.ts +3 -0
  122. package/src/rbac/permissions.ts +8 -0
  123. package/src/scheduler/schedule-task.ts +44 -3
  124. package/src/scheduler/scheduler.ts +4 -4
  125. package/src/scripts-runtime/loader.ts +8 -1
  126. package/src/slack/ack.ts +2 -1
  127. package/src/slack/reaction-shortcode.ts +4 -7
  128. package/src/tasks/worker-follow-up.ts +30 -0
  129. package/src/tests/approval-requests-notifications.test.ts +189 -0
  130. package/src/tests/chat-sdk-commands.test.ts +12 -0
  131. package/src/tests/chat-sdk-inbound.test.ts +163 -9
  132. package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
  133. package/src/tests/chat-sdk-relay.test.ts +361 -10
  134. package/src/tests/chat-sdk-tools.test.ts +104 -0
  135. package/src/tests/docs-index-footprint.test.js +3 -3
  136. package/src/tests/gitlab-auth.test.ts +13 -5
  137. package/src/tests/gitlab-handlers.test.ts +270 -22
  138. package/src/tests/http-users.test.ts +38 -0
  139. package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
  140. package/src/tests/rbac-engine.test.ts +2 -0
  141. package/src/tests/rbac-roles.test.ts +10 -0
  142. package/src/tests/schedule-http-triage-tools.test.ts +44 -0
  143. package/src/tests/script-api-credential.test.ts +173 -0
  144. package/src/tests/script-connections.test.ts +4 -0
  145. package/src/tests/script-run-config-scope.test.ts +120 -0
  146. package/src/tests/script-run-token.test.ts +75 -0
  147. package/src/tests/scripts-http.test.ts +19 -0
  148. package/src/tests/sessions-tree.test.ts +242 -0
  149. package/src/tests/user-roles-http.test.ts +204 -0
  150. package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
  151. package/src/tools/chat-reply.ts +2 -0
  152. package/src/tools/schedules/patch-schedule.ts +18 -0
  153. package/src/tools/schedules/update-schedule.ts +18 -0
  154. package/src/types.ts +16 -0
  155. package/src/workflows/approval-notifications.ts +214 -0
  156. package/src/workflows/executors/human-in-the-loop.ts +11 -172
  157. package/src/workflows/executors/swarm-script.ts +2 -2
  158. package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
  159. package/templates/skills/code-quality/content.md +1 -1
  160. package/dist/rbac-roles-exbg8xb6.js +0 -12
@@ -1,4 +1,4 @@
1
- import { type RunScriptOutput, runScript } from "../../scripts-runtime/loader";
1
+ import type { RunScriptOutput } from "../../scripts-runtime/loader";
2
2
  import type { ScriptRecord } from "../../types";
3
3
  import {
4
4
  getScriptApiConnectionDescriptors,
@@ -6,6 +6,7 @@ import {
6
6
  } from "../script-connections";
7
7
  import { buildScriptCredentialBindingsWithFailures } from "../script-credential-broker";
8
8
  import { restoreScratchScriptLastUsedIfUnchanged, touchScratchScriptLastUsed } from "./db";
9
+ import { runScriptWithRunCredential } from "./run-credential";
9
10
 
10
11
  export function getSavedScriptOwnerAgentId(script: ScriptRecord): string | null {
11
12
  return script.scopeId ?? script.createdByAgentId;
@@ -45,7 +46,7 @@ export async function runSavedScriptAsAgent(
45
46
  exitCode: 1,
46
47
  error: "killed",
47
48
  }
48
- : await runScript({
49
+ : await runScriptWithRunCredential({
49
50
  source: args.script.source,
50
51
  args: args.input,
51
52
  fsMode: args.script.fsMode,
package/src/be/users.ts CHANGED
@@ -615,6 +615,23 @@ export async function mintRuntimeToken(
615
615
  return await insertSessionToken(agentId, null, runtimeId, ttlMs);
616
616
  }
617
617
 
618
+ /**
619
+ * Mint the short-lived `aseph_` token a script run uses for its `ctx.swarm`
620
+ * SDK calls, so those calls present the executing agent instead of the swarm
621
+ * API key (the operator). It resolves as a plain agent principal (`kind:
622
+ * "agent"`, no task/runtime binding), so the API layer scopes it to that
623
+ * agent's permissions: a lead keeps lead powers, a worker reads only its own
624
+ * scope. Marked `scriptRun` so it stays apart from the agent's worker token
625
+ * (see `AGENT_TOKEN_BINDING`). The run revokes it when it ends; `ttlMs` is the
626
+ * backstop for a run that dies without revoking.
627
+ */
628
+ export async function mintScriptRunToken(
629
+ agentId: string,
630
+ ttlMs: number,
631
+ ): Promise<{ tokenId: string; plaintext: string }> {
632
+ return await insertSessionToken(agentId, null, null, ttlMs, null, undefined, null, null, true);
633
+ }
634
+
618
635
  export async function insertSessionToken(
619
636
  agentId: string,
620
637
  taskId: string | null,
@@ -624,6 +641,7 @@ export async function insertSessionToken(
624
641
  expiresAtCeiling?: string,
625
642
  controllerId: string | null = null,
626
643
  parentTokenId: string | null = null,
644
+ scriptRun = false,
627
645
  ): Promise<{ tokenId: string; plaintext: string }> {
628
646
  const plaintext = `${SESSION_TOKEN_PREFIX}${base62(randomBytes(24))}`;
629
647
  const tokenId = randomUUID().replace(/-/g, "");
@@ -636,8 +654,8 @@ export async function insertSessionToken(
636
654
 
637
655
  await getDbClient().run(
638
656
  `INSERT INTO session_tokens
639
- (id, tokenHash, tokenPreview, agentId, taskId, runtimeId, parentRuntimeId, expiresAt, createdAt, controllerId, parentTokenId)
640
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
657
+ (id, tokenHash, tokenPreview, agentId, taskId, runtimeId, parentRuntimeId, expiresAt, createdAt, controllerId, parentTokenId, scriptRun)
658
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
641
659
  [
642
660
  tokenId,
643
661
  hash,
@@ -650,6 +668,7 @@ export async function insertSessionToken(
650
668
  now.toISOString(),
651
669
  controllerId,
652
670
  parentTokenId,
671
+ scriptRun ? 1 : 0,
653
672
  ],
654
673
  );
655
674
 
@@ -713,9 +732,14 @@ export async function revokeRuntimeTokensForRuntime(runtimeId: string): Promise<
713
732
  // Agent tokens (long-lived, one agent, no runtime)
714
733
  // ---------------------------------------------------------------------------
715
734
 
716
- /** SQL predicate for an agent token: bound to its agent and nothing else. */
735
+ /**
736
+ * SQL predicate for an agent (worker) token: bound to its agent and nothing
737
+ * else. `scriptRun = 0` excludes a script run's ephemeral token, which shares
738
+ * the same all-null binding shape but has its own lifecycle (see migration 233
739
+ * and `mintScriptRunToken`).
740
+ */
717
741
  const AGENT_TOKEN_BINDING =
718
- "taskId IS NULL AND runtimeId IS NULL AND controllerId IS NULL AND parentRuntimeId IS NULL";
742
+ "taskId IS NULL AND runtimeId IS NULL AND controllerId IS NULL AND parentRuntimeId IS NULL AND scriptRun = 0";
719
743
 
720
744
  /** Stored expiry of a token that does not expire. `expiresAt` is NOT NULL. */
721
745
  export const AGENT_TOKEN_NO_EXPIRY = "9999-12-31T23:59:59.999Z";
@@ -53,8 +53,14 @@ export function getGitLabUrl(): string {
53
53
  return gitlabUrl;
54
54
  }
55
55
 
56
- /** Bot name for mention detection. */
57
- export const GITLAB_BOT_NAME = process.env.GITLAB_BOT_NAME ?? "agent-swarm-bot";
56
+ /**
57
+ * Bot name for mention detection. Read on every call: a stored swarm_config
58
+ * value reaches process.env after this module loads, and again on each config
59
+ * reload.
60
+ */
61
+ export function getGitLabBotName(): string {
62
+ return process.env.GITLAB_BOT_NAME || "agent-swarm-bot";
63
+ }
58
64
 
59
65
  /** Reset state for testing. */
60
66
  export function resetGitLab(): void {
@@ -8,13 +8,21 @@
8
8
  * - Detects bot mentions
9
9
  */
10
10
 
11
- import { failTask, findTaskByVcs, getAllAgents, incrKv, upsertKv } from "../be/db";
11
+ import {
12
+ failTask,
13
+ findActiveTasksByVcs,
14
+ findTaskByVcs,
15
+ getAllAgents,
16
+ incrKv,
17
+ upsertKv,
18
+ } from "../be/db";
12
19
  import { renderIdentity, resolveIdentity } from "../be/identity";
13
20
  import { findOrCreateUserByEmail, findUserByExternalId, linkIdentity } from "../be/users";
14
21
  import { resolveTemplate } from "../prompts/resolver";
15
22
  import { gitlabContextKey } from "../tasks/context-key";
16
23
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
17
- import { GITLAB_BOT_NAME } from "./auth";
24
+ import type { AgentTask } from "../types";
25
+ import { getGitLabBotName } from "./auth";
18
26
  import { addGitLabNoteReaction, addGitLabReaction } from "./reactions";
19
27
  import type { GitLabUser } from "./types";
20
28
  // Side-effect import: registers all GitLab event templates in the in-memory registry
@@ -40,11 +48,37 @@ function isDuplicate(key: string): boolean {
40
48
 
41
49
  function detectMention(text: string | null | undefined): boolean {
42
50
  if (!text) return false;
43
- return new RegExp(`@${GITLAB_BOT_NAME}\\b`, "i").test(text);
51
+ return new RegExp(`@${getGitLabBotName()}\\b`, "i").test(text);
44
52
  }
45
53
 
46
54
  function extractMentionContext(text: string): string {
47
- return text.replace(new RegExp(`@${GITLAB_BOT_NAME}\\b`, "gi"), "").trim();
55
+ return text.replace(new RegExp(`@${getGitLabBotName()}\\b`, "gi"), "").trim();
56
+ }
57
+
58
+ // Legacy rows written before contextKey existed: scoped only on positive evidence.
59
+ const LEGACY_SCOPE = {
60
+ mr: {
61
+ taskTypes: new Set(["gitlab-mr", "gitlab-ci"]),
62
+ eventTypes: new Set(["merge_request", "note_on_mr", "pipeline"]),
63
+ },
64
+ issue: {
65
+ taskTypes: new Set(["gitlab-issue"]),
66
+ eventTypes: new Set(["issue", "note_on_issue"]),
67
+ },
68
+ };
69
+
70
+ /**
71
+ * GitLab issue and MR iids are separate per-project sequences, so a vcsNumber
72
+ * match alone hits both issue #N and MR !N. Match on the contextKey instead; a
73
+ * row without one counts only when its taskType or vcsEventType names the kind.
74
+ */
75
+ function isScopedTask(task: AgentTask, kind: "mr" | "issue", contextKey: string): boolean {
76
+ if (task.contextKey) return task.contextKey === contextKey;
77
+ const legacy = LEGACY_SCOPE[kind];
78
+ return (
79
+ (task.taskType !== undefined && legacy.taskTypes.has(task.taskType)) ||
80
+ (task.vcsEventType !== undefined && legacy.eventTypes.has(task.vcsEventType))
81
+ );
48
82
  }
49
83
 
50
84
  async function findLeadAgent() {
@@ -206,12 +240,18 @@ export async function handleMergeRequest(
206
240
 
207
241
  case "close":
208
242
  case "merge": {
209
- // Cancel existing tasks for this MR
210
- const existingTask = await findTaskByVcs(repo, mr.iid, "gitlab");
211
- if (existingTask) {
212
- const reason = action === "merge" ? "MR was merged" : "MR was closed";
213
- console.log(`[GitLab] Cancelling task ${existingTask.id} — ${reason}`);
214
- await failTask(existingTask.id, reason);
243
+ // Every active task, not only the newest: a note task is a child of the
244
+ // MR task, and failTask does not cascade up to the parent.
245
+ const reason = action === "merge" ? "MR was merged" : "MR was closed";
246
+ const mrContextKey = gitlabContextKey({
247
+ projectId: String(project.id),
248
+ kind: "mr",
249
+ iid: mr.iid,
250
+ });
251
+ const tasks = await findActiveTasksByVcs(repo, mr.iid, "gitlab");
252
+ for (const task of tasks.filter((t) => isScopedTask(t, "mr", mrContextKey))) {
253
+ console.log(`[GitLab] Cancelling task ${task.id} — ${reason}`);
254
+ await failTask(task.id, reason);
215
255
  }
216
256
  return { created: false };
217
257
  }
@@ -262,7 +302,7 @@ export async function handleIssue(
262
302
  if (!hasMention) {
263
303
  // Check if bot is in assignees
264
304
  const isBotAssigned = event.assignees?.some(
265
- (a) => a.username.toLowerCase() === GITLAB_BOT_NAME.toLowerCase(),
305
+ (a) => a.username.toLowerCase() === getGitLabBotName().toLowerCase(),
266
306
  );
267
307
  if (!isBotAssigned) {
268
308
  console.log(`[GitLab] Issue opened without bot mention/assignment, skipping`);
@@ -318,7 +358,15 @@ export async function handleIssue(
318
358
  }
319
359
 
320
360
  case "close": {
321
- const existingTask = await findTaskByVcs(repo, issue.iid, "gitlab");
361
+ const issueContextKey = gitlabContextKey({
362
+ projectId: String(project.id),
363
+ kind: "issue",
364
+ iid: issue.iid,
365
+ });
366
+ // Newest issue-scoped task only; MR !N shares the iid but not the issue.
367
+ const existingTask = (await findActiveTasksByVcs(repo, issue.iid, "gitlab")).find((t) =>
368
+ isScopedTask(t, "issue", issueContextKey),
369
+ );
322
370
  if (existingTask) {
323
371
  console.log(`[GitLab] Cancelling task ${existingTask.id} — issue closed`);
324
372
  await failTask(existingTask.id, "Issue was closed");
@@ -1,6 +1,6 @@
1
1
  // GitLab Integration
2
2
  export {
3
- GITLAB_BOT_NAME,
3
+ getGitLabBotName,
4
4
  getGitLabToken,
5
5
  getGitLabUrl,
6
6
  initGitLab,
@@ -70,6 +70,7 @@ import "./trackers/linear";
70
70
  import "./ranking";
71
71
  import "./used-by";
72
72
  import "./fleet-benchmark";
73
+ import "./user-roles";
73
74
  import "./users";
74
75
  import "./webhooks";
75
76
  import "./workflow-events";
@@ -20,8 +20,12 @@ import { findUserById } from "../be/users";
20
20
  import { can, type RbacPrincipal } from "../rbac";
21
21
  import type { User } from "../types";
22
22
  import { getRequestAuth } from "../utils/request-auth-context";
23
+ import { scrubSecrets } from "../utils/secret-scrubber";
23
24
  import { cancelApprovalRequest } from "../workflows/approval-cancel";
24
- import { createApprovalFollowUpTask } from "../workflows/approval-notifications";
25
+ import {
26
+ createApprovalFollowUpTask,
27
+ dispatchApprovalNotifications,
28
+ } from "../workflows/approval-notifications";
25
29
  import { workflowEventBus } from "../workflows/event-bus";
26
30
  import { bodyTask, route } from "./route-def";
27
31
  import { jsonError } from "./utils";
@@ -288,7 +292,10 @@ const createRoute = route({
288
292
  target: z.string(),
289
293
  }),
290
294
  )
291
- .optional(),
295
+ .optional()
296
+ .describe(
297
+ "Where to announce the request. A Slack target (channel or user ID) gets a card with the title, the questions and a link to the dashboard page that answers it. Slack targets need a lead agent caller (403 otherwise). Email is not supported yet and is skipped.",
298
+ ),
292
299
  }),
293
300
  responses: {
294
301
  201: {
@@ -296,6 +303,7 @@ const createRoute = route({
296
303
  schema: z.object({ approvalRequest: ApprovalRequestSchema }),
297
304
  },
298
305
  400: { description: "Validation error" },
306
+ 403: { description: "Slack notifications require a lead agent" },
299
307
  },
300
308
  auth: { apiKey: true },
301
309
  });
@@ -578,7 +586,7 @@ export async function handleApprovalRequests(
578
586
  const parsed = await cancelRoute.parse(req, res, pathSegments, queryParams);
579
587
  if (!parsed) return true;
580
588
 
581
- const { principal, resolvedBy } = await approvalCancelPrincipal(req);
589
+ const { principal, resolvedBy } = await approvalCallerPrincipal(req);
582
590
  const result = await cancelApprovalRequest({
583
591
  id: parsed.params.id,
584
592
  reason: parsed.body.reason,
@@ -619,6 +627,17 @@ export async function handleApprovalRequests(
619
627
  const parsed = await createRoute.parse(req, res, pathSegments, queryParams);
620
628
  if (!parsed) return true;
621
629
 
630
+ // Posting to Slack is lead-only everywhere else (slack-post); a caller that
631
+ // may not post must not reach an arbitrary channel through this route.
632
+ if (parsed.body.notifications?.some((notification) => notification.channel === "slack")) {
633
+ const { principal } = await approvalCallerPrincipal(req);
634
+ const decision = can({ principal, verb: "integration.slack.post", source: "http" });
635
+ if (!decision.allow) {
636
+ jsonError(res, SLACK_NOTIFICATION_REFUSED, 403);
637
+ return true;
638
+ }
639
+ }
640
+
622
641
  const id = crypto.randomUUID();
623
642
  // Prefer a trusted authenticated user (never client-controlled); else fall
624
643
  // back to the ownership-validated sourceTaskId the request body carries.
@@ -642,6 +661,24 @@ export async function handleApprovalRequests(
642
661
  createdBy,
643
662
  });
644
663
 
664
+ // Fire-and-forget: Slack must not delay or fail the 201. Workflow HITL
665
+ // requests are created by the executor directly, so they never post here.
666
+ const notifications = parsed.body.notifications;
667
+ if (notifications?.length && request.status === "pending") {
668
+ dispatchApprovalNotifications({
669
+ requestId: request.id,
670
+ title: request.title,
671
+ questions: parsed.body.questions,
672
+ timeoutSeconds: parsed.body.timeoutSeconds,
673
+ notifications,
674
+ }).catch((err) => {
675
+ console.error(
676
+ "[approval-requests] Unexpected error dispatching notifications:",
677
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
678
+ );
679
+ });
680
+ }
681
+
645
682
  createRoute.respond(res, 201, {
646
683
  approvalRequest: await toApprovalRequestResponse(request),
647
684
  });
@@ -676,11 +713,14 @@ export async function handleApprovalRequests(
676
713
  return false;
677
714
  }
678
715
 
716
+ const SLACK_NOTIFICATION_REFUSED =
717
+ "Slack notifications require lead privileges (integration.slack.post). Create the request without them, or ask the lead to post the link.";
718
+
679
719
  /**
680
- * The caller of the cancel route. The shared API key with an `X-Agent-ID`
681
- * header identifies that agent, as on the create route.
720
+ * The caller of the create and cancel routes. The shared API key with an
721
+ * `X-Agent-ID` header identifies that agent.
682
722
  */
683
- async function approvalCancelPrincipal(
723
+ async function approvalCallerPrincipal(
684
724
  req: IncomingMessage,
685
725
  ): Promise<{ principal: RbacPrincipal; resolvedBy: string | null }> {
686
726
  const auth = getRequestAuth(req);
package/src/http/index.ts CHANGED
@@ -139,6 +139,7 @@ import { handleTasks } from "./tasks";
139
139
  import { handleTelemetryUi } from "./telemetry-ui";
140
140
  import { handleTrackers } from "./trackers";
141
141
  import { handleUsedBy } from "./used-by";
142
+ import { handleUserRoles } from "./user-roles";
142
143
  import { handleUsers } from "./users";
143
144
  import {
144
145
  getPathSegments,
@@ -404,6 +405,7 @@ const httpServer = createHttpServer(async (req, res) => {
404
405
  () => handleRuntimePools(req, res, pathSegments, queryParams, myAgentId),
405
406
  () => handleExtensions(req, res, pathSegments, queryParams, myAgentId),
406
407
  () => handleFavorites(req, res, pathSegments, queryParams, myAgentId),
408
+ () => handleUserRoles(req, res, pathSegments, queryParams),
407
409
  () => handleUsers(req, res, pathSegments, queryParams),
408
410
  () => handleSessions(req, res, pathSegments, queryParams, myAgentId),
409
411
  () => handleInboxState(req, res, pathSegments, queryParams),
@@ -19,6 +19,7 @@ import {
19
19
  import { explicitModelErrorForAgent } from "../be/model-validation";
20
20
  import { mergeScheduleTiming, validateRecurringTiming } from "../be/schedules/validate";
21
21
  import { getScript } from "../be/scripts/db";
22
+ import { can } from "../rbac";
22
23
  import { calculateNextRun, dispatchScheduleTarget } from "../scheduler/scheduler";
23
24
  import {
24
25
  AgentTaskSchema,
@@ -30,6 +31,7 @@ import {
30
31
  splitLegacyModelAlias,
31
32
  } from "../types";
32
33
  import { resolveHttpFavoriteOwner } from "./favorite-owner";
34
+ import { agentFirstPrincipal } from "./request-principal";
33
35
  import { route } from "./route-def";
34
36
  import { jsonError } from "./utils";
35
37
 
@@ -219,6 +221,7 @@ const updateSchedule = route({
219
221
  responses: {
220
222
  200: { description: "Schedule updated", schema: ScheduledTaskSchema.nullable() },
221
223
  400: { description: "Validation error" },
224
+ 403: { description: "Deferral owned by another agent" },
222
225
  404: { description: "Schedule not found" },
223
226
  409: { description: "Duplicate name" },
224
227
  },
@@ -237,6 +240,7 @@ const patchSchedule = route({
237
240
  responses: {
238
241
  200: { description: "Schedule patched", schema: ScheduledTaskSchema.nullable() },
239
242
  400: { description: "Validation error" },
243
+ 403: { description: "Deferral owned by another agent" },
240
244
  404: { description: "Schedule not found" },
241
245
  409: { description: "Duplicate name" },
242
246
  },
@@ -559,6 +563,28 @@ export async function handleSchedules(
559
563
  jsonError(res, "Schedule not found", 404);
560
564
  return true;
561
565
  }
566
+ // A deferral continues its owner's task, in that task's chat thread when
567
+ // it has one (src/scheduler/schedule-task.ts). An agent may change it only
568
+ // as its owner or a lead, as in the patch-schedule and update-schedule tools.
569
+ if (existing.taskType === "deferred") {
570
+ const principal = await agentFirstPrincipal(req, myAgentId);
571
+ if (
572
+ principal.kind === "agent" &&
573
+ !can({
574
+ principal,
575
+ verb: "schedule.deferral.update.any",
576
+ resource: { kind: "owned", ownerAgentId: existing.createdByAgentId },
577
+ source: "http",
578
+ }).allow
579
+ ) {
580
+ jsonError(
581
+ res,
582
+ `Schedule "${existing.name}" defers another agent's task. Only that agent or a lead can change it.`,
583
+ 403,
584
+ );
585
+ return true;
586
+ }
587
+ }
562
588
  // A move to another agent re-judges the stored model against the new harness.
563
589
  const agentChanged =
564
590
  parsed.body.targetAgentId !== undefined &&
@@ -32,6 +32,7 @@ import {
32
32
  } from "../be/scripts/db";
33
33
  import { searchScripts } from "../be/scripts/embeddings";
34
34
  import { extractArgsJsonSchema } from "../be/scripts/extract-schema";
35
+ import { runScriptWithRunCredential } from "../be/scripts/run-credential";
35
36
  import { refuseSourceWithSecrets } from "../be/scripts/source-secrets";
36
37
  import {
37
38
  scriptSdkTypesWithGeneratedApis,
@@ -40,7 +41,6 @@ import {
40
41
  } from "../be/scripts/typecheck";
41
42
  import { can } from "../rbac";
42
43
  import { extractScriptSignature, type ScriptSignature } from "../scripts-runtime/extract-signature";
43
- import { runScript } from "../scripts-runtime/loader";
44
44
  import { checkQuickJSCompat, quickJSCompatWarnings } from "../scripts-runtime/quickjs-compat";
45
45
  import {
46
46
  ScriptApiAuthModeSchema,
@@ -782,7 +782,7 @@ export async function handleScripts(
782
782
  ? await touchScratchScriptLastUsed(namedScript.id)
783
783
  : null;
784
784
  const credentials = await buildScriptCredentialBindingsWithFailures({ agentId: agent.id });
785
- const output = await runScript({
785
+ const output = await runScriptWithRunCredential({
786
786
  source: source as string,
787
787
  args: parsed.body.args,
788
788
  fsMode,
@@ -26,7 +26,7 @@ import {
26
26
  moveSessionChains,
27
27
  type SessionKeyMove,
28
28
  } from "../be/db/session-moves";
29
- import { getSessionReadState, getSessionTree } from "../be/db/session-tree";
29
+ import { getSessionReadState, getSessionTree, triageSessionList } from "../be/db/session-tree";
30
30
  import { getUserGrant } from "../be/rbac-roles";
31
31
  import {
32
32
  getSessionRoot,
@@ -157,6 +157,9 @@ const SessionTreeResponseSchema = z.object({
157
157
  later: SessionSectionCountsSchema,
158
158
  done: SessionSectionCountsSchema,
159
159
  noGroup: SessionSectionCountsSchema,
160
+ sources: z
161
+ .array(z.object({ source: z.string(), total: z.number().int() }))
162
+ .describe("Each root source in this view with its session count, hidden sources included."),
160
163
  });
161
164
 
162
165
  const SessionApprovalSchema = z.object({
@@ -480,6 +483,28 @@ async function handleAgentTokens(
480
483
  return true;
481
484
  }
482
485
 
486
+ const SessionShowSchema = z
487
+ .enum(["mine", "all"])
488
+ .optional()
489
+ .describe(
490
+ "Sessions v2 view, for a user viewer with tree scope. mine (default): roots the user started from a human channel (not workflow, schedule, or system) plus sessions shared with the user. all: every tree session. Lists only: access is unchanged.",
491
+ );
492
+
493
+ const SessionHideSourceSchema = z
494
+ .string()
495
+ // The same cap as the `hideSource` body field of `POST /api/session-triage`.
496
+ .refine((value) => value.split(",").length <= 32, "hideSource takes at most 32 sources.")
497
+ .optional()
498
+ .transform((value) =>
499
+ value
500
+ ?.split(",")
501
+ .map((source) => source.trim())
502
+ .filter(Boolean),
503
+ )
504
+ .describe(
505
+ "Comma-separated root sources to leave out (the sessions v2 source filter), e.g. `slack,chat`.",
506
+ );
507
+
483
508
  const listSessions = route({
484
509
  method: "get",
485
510
  path: "/api/sessions",
@@ -539,6 +564,10 @@ const listSessions = route({
539
564
  .describe(
540
565
  "mine: owned sessions. shared: sessions shared with me. tree: Sessions that GET /api/session-tree counts: mine plus shared with me, no ownerless roots. A bypass actor sees every root with tree scope.",
541
566
  ),
567
+ /** With `scope=tree`: `mine` (default) or `all`. Must match the tree's `show`. */
568
+ show: SessionShowSchema,
569
+ /** Root sources to leave out. Must match the tree's `hideSource`. */
570
+ hideSource: SessionHideSourceSchema,
542
571
  }),
543
572
  responses: {
544
573
  200: {
@@ -561,6 +590,7 @@ const sessionTree = route({
561
590
  pattern: ["api", "session-tree"],
562
591
  summary: "Get session groups and triage counts",
563
592
  tags: ["Sessions"],
593
+ query: z.object({ show: SessionShowSchema, hideSource: SessionHideSourceSchema }),
564
594
  responses: {
565
595
  200: { description: "Visible groups and section counts", schema: SessionTreeResponseSchema },
566
596
  401: { description: "Unauthorized" },
@@ -568,6 +598,39 @@ const sessionTree = route({
568
598
  auth: { apiKey: true },
569
599
  });
570
600
 
601
+ const triageSessionListRoute = route({
602
+ method: "post",
603
+ path: "/api/session-triage",
604
+ pattern: ["api", "session-triage"],
605
+ summary: "Triage every session of one sidebar list",
606
+ description:
607
+ "The bulk bar's 'Select all N': Done, back to the Inbox, read, or unread for every session that one sessions v2 list shows the caller (the `GET /api/sessions?scope=tree` filters). Needs a picked user.",
608
+ tags: ["Sessions"],
609
+ body: z
610
+ .object({
611
+ action: z.enum(["done", "inbox", "read", "unread"]),
612
+ /** The list: one of Inbox, Later, Done, No group, or a group. */
613
+ triage: z.enum(["inbox", "later", "done"]).optional(),
614
+ noGroup: z.boolean().optional(),
615
+ groupKey: SessionKeyFilterSchema.optional(),
616
+ show: SessionShowSchema,
617
+ hideSource: z.array(z.string().min(1)).max(32).optional(),
618
+ })
619
+ .refine((body) => [body.triage, body.noGroup, body.groupKey].filter(Boolean).length === 1, {
620
+ message: "Name exactly one list: triage, noGroup, or groupKey",
621
+ }),
622
+ responses: {
623
+ 200: {
624
+ description: "The number of sessions written",
625
+ schema: z.object({ updated: z.number().int() }),
626
+ },
627
+ 400: { description: "Validation error, or no picked user" },
628
+ 401: { description: "Unauthorized" },
629
+ },
630
+ auth: { apiKey: true },
631
+ rbac: { ungated: "per-user inbox UI state, same posture as PATCH /api/inbox-state" },
632
+ });
633
+
571
634
  const SESSION_MOVE_LIMIT = 200;
572
635
  const SESSION_MOVE_OWNER_ONLY = "Only the session owner can move this group";
573
636
  const SESSION_MOVE_UNAVAILABLE = "Session not found or not accessible";
@@ -877,6 +940,7 @@ export async function handleSessions(
877
940
  }
878
941
  const filterOpts = {
879
942
  source: sources,
943
+ hideSource: parsed.query.hideSource,
880
944
  q: parsed.query.q,
881
945
  requestedByUserId: parsed.query.requestedByUserId,
882
946
  contextKeyPrefix: parsed.query.contextKeyPrefix,
@@ -888,6 +952,7 @@ export async function handleSessions(
888
952
  withUserId: parsed.query.withUserId,
889
953
  now: new Date().toISOString(),
890
954
  tree: parsed.query.scope === "tree",
955
+ mine: parsed.query.scope === "tree" && parsed.query.show !== "all",
891
956
  ...(actor.kind === "user"
892
957
  ? {
893
958
  viewer: {
@@ -922,7 +987,33 @@ export async function handleSessions(
922
987
  if (sessionTree.match(req.method, pathSegments)) {
923
988
  const parsed = await sessionTree.parse(req, res, pathSegments, queryParams);
924
989
  if (!parsed) return true;
925
- sessionTree.respond(res, 200, await getSessionTree(await resolveSessionActor(req, myAgentId)));
990
+ const actor = await resolveSessionActor(req, myAgentId);
991
+ sessionTree.respond(
992
+ res,
993
+ 200,
994
+ await getSessionTree(actor, undefined, {
995
+ mine: parsed.query.show !== "all",
996
+ hideSource: parsed.query.hideSource,
997
+ }),
998
+ );
999
+ return true;
1000
+ }
1001
+
1002
+ if (triageSessionListRoute.match(req.method, pathSegments)) {
1003
+ const parsed = await triageSessionListRoute.parse(req, res, pathSegments, queryParams);
1004
+ if (!parsed) return true;
1005
+ const actor = await resolveSessionActor(req, myAgentId);
1006
+ if (actor.kind !== "user" || !actor.userId) {
1007
+ jsonError(res, "Pick a user to triage sessions", 400);
1008
+ return true;
1009
+ }
1010
+ const { action, show, ...list } = parsed.body;
1011
+ const updated = await triageSessionList(
1012
+ actor.userId,
1013
+ { ...list, mine: show !== "all" },
1014
+ action,
1015
+ );
1016
+ triageSessionListRoute.respond(res, 200, { updated });
926
1017
  return true;
927
1018
  }
928
1019