@desplega.ai/agent-swarm 2.5.1 → 2.5.3

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 (197) hide show
  1. package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-gr2fcyyj.js} +3 -3
  2. package/dist/{actions-w8xd014k.js → actions-wv766ghf.js} +18 -18
  3. package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-speb906t.js} +3 -3
  4. package/dist/{app-nndcvftq.js → app-ag29n14f.js} +3 -3
  5. package/dist/{assistant-hjmqyscx.js → assistant-3x8bz3eg.js} +19 -19
  6. package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-5b3d2eea.js} +1 -1
  7. package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-jsmfrpzf.js} +1 -1
  8. package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-s73gerxy.js} +2 -2
  9. package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-5zmax2wr.js} +4 -4
  10. package/dist/{claude-adapter-2f8b85df.js → claude-adapter-hswdnm3t.js} +3 -3
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
  12. package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3k8m3hhc.js} +2 -2
  13. package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-fkbyk66x.js} +6905 -6855
  14. package/dist/{cli-y3zyaee5.js → cli-1fc85mvt.js} +5 -5
  15. package/dist/{cli-v2g93k4v.js → cli-210xd1f4.js} +4 -4
  16. package/dist/{cli-sn1vbtg2.js → cli-25j01dpj.js} +3 -3
  17. package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
  18. package/dist/{cli-r9fy0ddc.js → cli-3ngdk5te.js} +1 -1
  19. package/dist/{cli-z63a269p.js → cli-40jsnrjm.js} +2 -2
  20. package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
  21. package/dist/{cli-qfm6my86.js → cli-502eqv93.js} +1 -1
  22. package/dist/{cli-xd4eqtr2.js → cli-6n8axjv1.js} +1 -1
  23. package/dist/{cli-hadv0725.js → cli-8s5wx3nz.js} +2 -2
  24. package/dist/{cli-z2dv25mf.js → cli-99xgw26r.js} +116 -5
  25. package/dist/{cli-jpxsrxcb.js → cli-9s05422w.js} +1 -1
  26. package/dist/{cli-kcsd1ez8.js → cli-aecdcr77.js} +343 -325
  27. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  28. package/dist/{cli-e9btqw4y.js → cli-ce0ak9ms.js} +18 -13
  29. package/dist/{cli-nj12tn2c.js → cli-e2ar4bp1.js} +71 -20
  30. package/dist/{cli-gwcf52gz.js → cli-e8jyq7d8.js} +11 -7
  31. package/dist/{cli-rngde2jv.js → cli-egjggehm.js} +1 -1
  32. package/dist/{cli-qfjm4gvt.js → cli-ewm3pwbq.js} +2 -2
  33. package/dist/{cli-9mm5ade4.js → cli-fsdprwrg.js} +1 -1
  34. package/dist/{cli-3ee5z0q0.js → cli-fzcn3d87.js} +1 -1
  35. package/dist/{cli-eym3jg7w.js → cli-gpsz7g88.js} +6 -6
  36. package/dist/{cli-cwyafhw1.js → cli-hcpj2xqz.js} +1 -1
  37. package/dist/{cli-j1eghqfm.js → cli-hhxjnjzs.js} +1 -1
  38. package/dist/{cli-pyy4c9ch.js → cli-jjznk3f7.js} +881 -232
  39. package/dist/{cli-q9pzwxvk.js → cli-kxawjrxg.js} +222 -37
  40. package/dist/{cli-rh5smt8t.js → cli-mz9afxxf.js} +14 -3
  41. package/dist/{cli-mz2ygeyq.js → cli-n45mr7zh.js} +2 -2
  42. package/dist/{cli-fes51ya2.js → cli-p0rzjkya.js} +2 -2
  43. package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
  44. package/dist/{cli-mq7kg1b1.js → cli-p9rafdrb.js} +41 -6
  45. package/dist/{cli-cah5detr.js → cli-qgf8emrn.js} +5 -5
  46. package/dist/{cli-2wba1ac9.js → cli-qzme5949.js} +26 -26
  47. package/dist/{cli-znvq0k3t.js → cli-s49hpn37.js} +2 -2
  48. package/dist/{cli-xc65x52q.js → cli-s53vp709.js} +1 -1
  49. package/dist/{cli-0jy85zz4.js → cli-svrj71pg.js} +18 -16
  50. package/dist/{cli-1dp1zj04.js → cli-vwn81jbs.js} +3 -3
  51. package/dist/{cli-ajqhxtnz.js → cli-w1zwctj2.js} +1 -1
  52. package/dist/{cli-22rmcjkg.js → cli-y561h5a9.js} +7 -6
  53. package/dist/{cli-2jg61wab.js → cli-z21whrrg.js} +15 -15
  54. package/dist/cli.js +19 -18
  55. package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-n8ka66vf.js} +2 -2
  56. package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-fsebts37.js} +1 -1
  57. package/dist/{commands-mbvhbrcy.js → commands-t04rt2pc.js} +2 -2
  58. package/dist/{controller-gnq1k1mf.js → controller-rgna04r2.js} +2 -2
  59. package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-bqm1rtft.js} +20 -20
  60. package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-swr2k95y.js} +1 -1
  61. package/dist/{db-88wcymxt.js → db-24ck09xy.js} +1 -1
  62. package/dist/{db-af9y8nkf.js → db-a4m7haw0.js} +3 -3
  63. package/dist/{dispatcher-606ydx5v.js → dispatcher-nwf9ss0q.js} +17 -17
  64. package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-kpdnhjst.js} +2 -2
  65. package/dist/{external-channel-y8q5z8z2.js → external-channel-j1e2p34p.js} +2 -2
  66. package/dist/{grok-adapter-39aha52t.js → grok-adapter-qtjz0whk.js} +4 -4
  67. package/dist/{handlers-rk9dqech.js → handlers-19xr6dnz.js} +20 -20
  68. package/dist/{heartbeat-jfe9zfjr.js → heartbeat-vtesda5d.js} +20 -20
  69. package/dist/{hook-b1mez1m8.js → hook-8wqaszda.js} +1 -1
  70. package/dist/{hook-ffevzed1.js → hook-b2hhj6rd.js} +1 -1
  71. package/dist/{http-d5b44f1r.js → http-mxcqhhcs.js} +3020 -2463
  72. package/dist/{index-5en23s5z.js → index-1r8wfpzs.js} +1 -1
  73. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  74. package/dist/{index-mj46rk9k.js → index-kw2mmat7.js} +20 -20
  75. package/dist/{index-nq8hhv28.js → index-n1vbvta9.js} +19 -10
  76. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  77. package/dist/{index-d2qeem97.js → index-spwxjgay.js} +17 -17
  78. package/dist/{index-4q3vj1yr.js → index-srg522m6.js} +17 -17
  79. package/dist/{keepalive-wsby1btq.js → keepalive-dsd8rtgx.js} +3 -3
  80. package/dist/{lead-paa141j6.js → lead-zyerk1x2.js} +30 -30
  81. package/dist/{maintenance-vnshcyv9.js → maintenance-jqmqwnet.js} +1 -1
  82. package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
  83. package/dist/{onboard-nr4xhpzy.js → onboard-w4yd7s8t.js} +2 -2
  84. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
  85. package/dist/{otel-impl-ytn9wyv9.js → otel-impl-f36byzbd.js} +1 -1
  86. package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-xvak5p4v.js} +3 -3
  87. package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-682k2mt5.js} +12 -2
  88. package/dist/rbac-roles-gfn30fb8.js +26 -0
  89. package/dist/{render-v2-pyfsg9zk.js → render-v2-hgytz8nn.js} +3 -3
  90. package/dist/{resume-phj8xhqr.js → resume-2dvq348g.js} +7 -5
  91. package/dist/{session-access-jzx2z9h5.js → session-access-7f21ekq9.js} +4 -4
  92. package/dist/{setup-rfg0f0sg.js → setup-epffxtbk.js} +1 -1
  93. package/dist/{work-objects-44z2yjg8.js → work-objects-xz1a5rqk.js} +20 -20
  94. package/dist/{worker-zsfqzxhm.js → worker-42t8emjt.js} +30 -30
  95. package/openapi.json +3163 -2114
  96. package/package.json +7 -6
  97. package/src/be/approval-config-question.ts +389 -0
  98. package/src/be/automation-preflight.ts +37 -3
  99. package/src/be/db/session-tree.ts +158 -13
  100. package/src/be/db/session-visibility.ts +31 -0
  101. package/src/be/db/skill-search.ts +87 -0
  102. package/src/be/db/tasks/read.ts +47 -7
  103. package/src/be/db/workflow-runs.ts +54 -7
  104. package/src/be/db.ts +27 -5
  105. package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
  106. package/src/be/migrations/235_chat_relay_defer.sql +86 -0
  107. package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
  108. package/src/be/migrations/239_skills_fts.sql +40 -0
  109. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  110. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  111. package/src/be/rbac-roles.ts +138 -0
  112. package/src/be/scripts/run-credential.ts +64 -0
  113. package/src/be/scripts/run-saved.ts +3 -2
  114. package/src/be/swarm-config-guard.ts +23 -0
  115. package/src/be/users.ts +28 -4
  116. package/src/extensions/catalog.generated.json +4 -4
  117. package/src/http/all-routes.ts +1 -0
  118. package/src/http/approval-requests.ts +254 -43
  119. package/src/http/config.ts +22 -37
  120. package/src/http/index.ts +2 -0
  121. package/src/http/schedules.ts +26 -0
  122. package/src/http/scripts.ts +2 -2
  123. package/src/http/sessions.ts +118 -2
  124. package/src/http/user-roles.ts +173 -0
  125. package/src/http/users.ts +9 -0
  126. package/src/http/x.ts +2 -2
  127. package/src/integrations/chat-sdk/commands.ts +14 -1
  128. package/src/integrations/chat-sdk/inbound.ts +42 -6
  129. package/src/integrations/chat-sdk/outbound.ts +35 -10
  130. package/src/integrations/chat-sdk/reactions.ts +107 -0
  131. package/src/integrations/chat-sdk/relay.ts +75 -7
  132. package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
  133. package/src/integrations/reaction-events.ts +20 -0
  134. package/src/providers/amp-adapter.ts +1 -1
  135. package/src/providers/dsh-adapter.ts +1 -1
  136. package/src/rbac/legacy-policy.ts +3 -0
  137. package/src/rbac/permissions.ts +8 -0
  138. package/src/scheduler/schedule-task.ts +44 -3
  139. package/src/scheduler/scheduler.ts +4 -4
  140. package/src/scripts-runtime/loader.ts +8 -1
  141. package/src/slack/ack.ts +2 -1
  142. package/src/slack/reaction-shortcode.ts +4 -7
  143. package/src/tasks/worker-follow-up.ts +30 -0
  144. package/src/tests/approval-config-question.test.ts +702 -0
  145. package/src/tests/approval-requests-notifications.test.ts +189 -0
  146. package/src/tests/chat-sdk-commands.test.ts +12 -0
  147. package/src/tests/chat-sdk-inbound.test.ts +183 -9
  148. package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
  149. package/src/tests/chat-sdk-relay.test.ts +361 -10
  150. package/src/tests/chat-sdk-tools.test.ts +104 -0
  151. package/src/tests/docs-index-footprint.test.js +3 -3
  152. package/src/tests/dsh-adapter.test.ts +1 -1
  153. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  154. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  155. package/src/tests/http-users.test.ts +38 -0
  156. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  157. package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
  158. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  159. package/src/tests/rbac-engine.test.ts +2 -0
  160. package/src/tests/rbac-roles.test.ts +10 -0
  161. package/src/tests/schedule-http-triage-tools.test.ts +44 -0
  162. package/src/tests/script-api-credential.test.ts +173 -0
  163. package/src/tests/script-connections.test.ts +10 -0
  164. package/src/tests/script-run-config-scope.test.ts +120 -0
  165. package/src/tests/script-run-token.test.ts +75 -0
  166. package/src/tests/scripts-http.test.ts +19 -0
  167. package/src/tests/sessions-tree.test.ts +410 -1
  168. package/src/tests/skill-search-long-content.test.ts +193 -0
  169. package/src/tests/skill-search.test.ts +173 -22
  170. package/src/tests/user-roles-http.test.ts +204 -0
  171. package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
  172. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  173. package/src/tools/chat-reply.ts +2 -0
  174. package/src/tools/request-human-input.ts +27 -5
  175. package/src/tools/schedules/patch-schedule.ts +18 -0
  176. package/src/tools/schedules/update-schedule.ts +18 -0
  177. package/src/types.ts +23 -0
  178. package/src/workflows/approval-notifications.ts +221 -0
  179. package/src/workflows/checkpoint.ts +69 -35
  180. package/src/workflows/engine.ts +234 -87
  181. package/src/workflows/executors/base.ts +6 -0
  182. package/src/workflows/executors/human-in-the-loop.ts +36 -173
  183. package/src/workflows/executors/sub-workflow.ts +45 -16
  184. package/src/workflows/executors/swarm-script.ts +2 -2
  185. package/src/workflows/recovery.ts +63 -1
  186. package/src/workflows/resume.ts +60 -21
  187. package/src/workflows/retry-poller.ts +25 -4
  188. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  189. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  190. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  191. package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
  192. package/templates/skills/code-quality/content.md +1 -1
  193. package/templates/skills/turso-interaction/content.md +1 -1
  194. package/templates/skills/workflow-iterate/content.md +8 -1
  195. package/dist/rbac-roles-ff1ffmec.js +0 -12
  196. /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
  197. /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
package/src/be/db.ts CHANGED
@@ -208,6 +208,7 @@ import {
208
208
  sessionPageFields,
209
209
  sessionSummaryQuery,
210
210
  } from "./db/session-tree";
211
+ import { searchSkillRowsRanked } from "./db/skill-search";
211
212
  import { classifyTaskHumanFree, reclassifyTaskHumanFree } from "./db/tasks/human-free";
212
213
  import {
213
214
  type AgentTaskRow,
@@ -7842,6 +7843,8 @@ type WorkflowRunRow = {
7842
7843
  finishedAt: string | null;
7843
7844
  /** The `sub-workflow` step that started this run (migration 203). */
7844
7845
  parentStepId?: string | null;
7846
+ /** That step's attempt (`retryCount`) when it started this run (migration 240). */
7847
+ parentStepAttempt?: number | null;
7845
7848
  };
7846
7849
 
7847
7850
  /**
@@ -7862,6 +7865,7 @@ function rowToWorkflowRun(row: WorkflowRunRow, view: WorkflowPayloadView = "repl
7862
7865
  lastUpdatedAt: normalizeDateRequired(row.lastUpdatedAt),
7863
7866
  finishedAt: normalizeDate(row.finishedAt) ?? undefined,
7864
7867
  parentStepId: row.parentStepId ?? undefined,
7868
+ parentStepAttempt: row.parentStepId ? (row.parentStepAttempt ?? 0) : undefined,
7865
7869
  };
7866
7870
  return defineWorkflowPayload(run, "context", row.context, row.context_replay, view, {
7867
7871
  runId: row.id,
@@ -7876,10 +7880,12 @@ export async function createWorkflowRun(data: {
7876
7880
  triggerData?: unknown;
7877
7881
  createdBy?: string;
7878
7882
  parentStepId?: string;
7883
+ /** The parent step's `retryCount` when it started this run (migration 240). */
7884
+ parentStepAttempt?: number;
7879
7885
  }): Promise<WorkflowRun> {
7880
7886
  const now = new Date().toISOString();
7881
7887
  const row = await getDbClient().get<WorkflowRunRow>(
7882
- `INSERT INTO workflow_runs (id, workflowId, triggerType, startedAt, triggerData, created_by, parentStepId) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *`,
7888
+ `INSERT INTO workflow_runs (id, workflowId, triggerType, startedAt, triggerData, created_by, parentStepId, parentStepAttempt) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
7883
7889
  [
7884
7890
  data.id,
7885
7891
  data.workflowId,
@@ -7888,6 +7894,7 @@ export async function createWorkflowRun(data: {
7888
7894
  data.triggerData ? scrubJsonValue(data.triggerData) : null,
7889
7895
  data.createdBy ?? null,
7890
7896
  data.parentStepId ?? null,
7897
+ data.parentStepAttempt ?? 0,
7891
7898
  ],
7892
7899
  );
7893
7900
  if (!row) throw new Error("Failed to create workflow run");
@@ -7992,9 +7999,10 @@ export type WorkflowRunListOptions = {
7992
7999
  };
7993
8000
 
7994
8001
  // Everything but `context`. `triggerData` stays: it is small, and stored
7995
- // scripts read it off list rows (dedupe by trigger, failure reports).
8002
+ // scripts read it off list rows (dedupe by trigger, failure reports). The
8003
+ // parent link and attempt stay so a list row identifies a child's invocation.
7996
8004
  const WORKFLOW_RUN_SUMMARY_COLUMNS =
7997
- "id, workflowId, status, triggerType, triggerData, error, created_by, startedAt, lastUpdatedAt, finishedAt";
8005
+ "id, workflowId, status, triggerType, triggerData, error, created_by, startedAt, lastUpdatedAt, finishedAt, parentStepId, parentStepAttempt";
7998
8006
 
7999
8007
  export type WorkflowRunPage = {
8000
8008
  runs: WorkflowRun[];
@@ -10989,11 +10997,23 @@ export async function listSkills(filters?: SkillFilters): Promise<Skill[]> {
10989
10997
  return rows.map(rowToSkill);
10990
10998
  }
10991
10999
 
11000
+ /**
11001
+ * Ranked skill search (see searchSkillRowsRanked). Falls back to the unranked
11002
+ * substring match when the ranked query finds nothing, e.g. a partial word or
11003
+ * a query of only stopwords. The fallback also ranks an exact name first.
11004
+ */
10992
11005
  export async function searchSkills(
10993
11006
  query: string,
10994
11007
  limit = 20,
10995
11008
  includeContent = true,
10996
11009
  ): Promise<Skill[]> {
11010
+ const ranked = await searchSkillRowsRanked<SkillRow>(
11011
+ query,
11012
+ limit,
11013
+ includeContent === false ? SKILL_SLIM_COLUMNS : "skills.*",
11014
+ );
11015
+ if (ranked.length > 0) return ranked.map(rowToSkill);
11016
+
10997
11017
  const tokens = query.split(/\s+/).filter(Boolean);
10998
11018
  const columns = includeContent === false ? SKILL_SLIM_COLUMNS : "*";
10999
11019
  const searchClauses = tokens.map(() => "(name LIKE ? OR description LIKE ? OR content LIKE ?)");
@@ -11002,8 +11022,8 @@ export async function searchSkills(
11002
11022
  return [term, term, term];
11003
11023
  });
11004
11024
  const rows = await getDbClient().query<SkillRow>(
11005
- `SELECT ${columns} FROM skills WHERE ${[...searchClauses, "isEnabled = 1"].join(" AND ")} ORDER BY name ASC LIMIT ?`,
11006
- [...params, limit],
11025
+ `SELECT ${columns} FROM skills WHERE ${[...searchClauses, "isEnabled = 1"].join(" AND ")} ORDER BY lower(name) = lower(?) DESC, name ASC LIMIT ?`,
11026
+ [...params, query.trim(), limit],
11007
11027
  );
11008
11028
  return rows.map(rowToSkill);
11009
11029
  }
@@ -12629,6 +12649,8 @@ export async function upsertInboxState(opts: {
12629
12649
 
12630
12650
  // SQLite upsert via UNIQUE(userId, itemType, itemId). readAt is sticky: the
12631
12651
  // first non-null value written wins on every subsequent conflict.
12652
+ // `triageSessionList` in src/be/db/session-tree.ts copies these conflict
12653
+ // rules for whole-list triage. Keep the two in step.
12632
12654
  const row = await getDbClient().get<InboxItemStateRow>(
12633
12655
  `INSERT INTO inbox_item_state (userId, itemType, itemId, status, snoozeUntil, dismissedAt, doneAt, readAt, lastReadAt, createdAt, lastUpdatedAt)
12634
12656
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -0,0 +1,8 @@
1
+ -- Mark the ephemeral session token a script run uses for its ctx.swarm SDK
2
+ -- calls, so it stands in for the executing agent instead of the operator key.
3
+ -- It shares the all-null binding shape of an agent (worker) token, so this
4
+ -- flag is what keeps the two lifecycles apart: worker-token queries
5
+ -- (AGENT_TOKEN_BINDING) filter on scriptRun = 0, so minting or revoking an
6
+ -- agent's worker token never touches a script run's in-flight token, and a
7
+ -- script run's token never counts as the agent's worker token.
8
+ ALTER TABLE session_tokens ADD COLUMN scriptRun INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,86 @@
1
+ -- Chat relay and defer-task (see 217_chat_sdk_state.sql for the obligation table).
2
+ --
3
+ -- 1. A deferred task posts nothing. `defer-task` completes the task with a
4
+ -- "deferred until ..." placeholder, which the terminal trigger enqueues like
5
+ -- any answer. `completeTask` writes `deferredAt` after the status, in the same
6
+ -- transaction, so this trigger drops the obligation before it commits.
7
+ -- 2. The wake-up task (`source='schedule'`, `taskType='deferred'`) keeps the
8
+ -- chat contextKey of the task it continues (src/scheduler/schedule-task.ts),
9
+ -- and relays like a chat task: it carries the real answer. The scheduler
10
+ -- grants the key only to the deferring agent continuing a task that itself
11
+ -- answered in chat; the triggers also require that the wake-up runs as the
12
+ -- same agent on the same thread as its parent.
13
+
14
+ CREATE TRIGGER drop_chat_relay_on_defer
15
+ AFTER UPDATE OF deferredAt ON agent_tasks
16
+ WHEN NEW.deferredAt IS NOT NULL AND OLD.deferredAt IS NULL
17
+ BEGIN
18
+ DELETE FROM chat_relay_obligations
19
+ WHERE task_id = NEW.id AND delivered_at IS NULL;
20
+ END;
21
+
22
+ -- Placeholders already queued by earlier deferrals.
23
+ DELETE FROM chat_relay_obligations
24
+ WHERE delivered_at IS NULL
25
+ AND given_up_at IS NULL
26
+ AND task_id IN (SELECT id FROM agent_tasks WHERE deferredAt IS NOT NULL);
27
+
28
+ DROP TRIGGER IF EXISTS enqueue_chat_relay_after_terminal_update;
29
+ DROP TRIGGER IF EXISTS enqueue_chat_relay_after_terminal_insert;
30
+
31
+ CREATE TRIGGER enqueue_chat_relay_after_terminal_update
32
+ AFTER UPDATE OF status ON agent_tasks
33
+ WHEN OLD.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
34
+ AND NEW.status IN ('completed', 'failed', 'cancelled')
35
+ AND NEW.contextKey GLOB 'task:chat-sdk:*'
36
+ AND (
37
+ NEW.source = 'chat'
38
+ OR (NEW.source = 'system' AND NEW.taskType = 'follow-up')
39
+ OR (
40
+ NEW.source = 'schedule'
41
+ AND NEW.taskType = 'deferred'
42
+ AND EXISTS (
43
+ SELECT 1 FROM agent_tasks parent
44
+ WHERE parent.id = NEW.parentTaskId
45
+ AND parent.agentId = NEW.agentId
46
+ AND parent.contextKey = NEW.contextKey
47
+ )
48
+ )
49
+ OR (
50
+ NEW.source = 'system'
51
+ AND NEW.taskType = 'resume'
52
+ AND EXISTS (SELECT 1 FROM agents WHERE agents.id = NEW.agentId AND agents.isLead = 1)
53
+ )
54
+ )
55
+ BEGIN
56
+ INSERT OR IGNORE INTO chat_relay_obligations (task_id)
57
+ VALUES (NEW.id);
58
+ END;
59
+
60
+ CREATE TRIGGER enqueue_chat_relay_after_terminal_insert
61
+ AFTER INSERT ON agent_tasks
62
+ WHEN NEW.status IN ('completed', 'failed', 'cancelled')
63
+ AND NEW.contextKey GLOB 'task:chat-sdk:*'
64
+ AND (
65
+ NEW.source = 'chat'
66
+ OR (NEW.source = 'system' AND NEW.taskType = 'follow-up')
67
+ OR (
68
+ NEW.source = 'schedule'
69
+ AND NEW.taskType = 'deferred'
70
+ AND EXISTS (
71
+ SELECT 1 FROM agent_tasks parent
72
+ WHERE parent.id = NEW.parentTaskId
73
+ AND parent.agentId = NEW.agentId
74
+ AND parent.contextKey = NEW.contextKey
75
+ )
76
+ )
77
+ OR (
78
+ NEW.source = 'system'
79
+ AND NEW.taskType = 'resume'
80
+ AND EXISTS (SELECT 1 FROM agents WHERE agents.id = NEW.agentId AND agents.isLead = 1)
81
+ )
82
+ )
83
+ BEGIN
84
+ INSERT OR IGNORE INTO chat_relay_obligations (task_id)
85
+ VALUES (NEW.id);
86
+ END;
@@ -0,0 +1,75 @@
1
+ -- Sessions v2: a one-time Inbox cleanup. Until now a session with no triage
2
+ -- row stayed in the Inbox forever, so long-time users opened an Inbox with
3
+ -- thousands of old sessions.
4
+ --
5
+ -- For every user who sees a session in the sessions v2 tree (the owner, a
6
+ -- direct share, or a share with everyone), the session moves to Done when it
7
+ -- reads as "read" for that user, its chain had no activity in the last 7
8
+ -- days, its latest task is finished (completed, failed, cancelled, or
9
+ -- superseded), and no approval in it waits for an answer. A stuck running
10
+ -- session or a pending approval stays in the Inbox. "Read" is the rule of
11
+ -- `sessionReadStateSql` in src/be/db/session-tree.ts: no triage row (quiet
12
+ -- for 7 days is read), or an open row whose read cursor covers the newest
13
+ -- activity. Snoozed, done,
14
+ -- dismissed, and unread sessions keep their state. New activity brings a
15
+ -- Done session back to the Inbox as unread, as for a session marked Done by
16
+ -- hand. Ownerless roots are not in the tree and get no rows.
17
+
18
+ CREATE TEMP TABLE _quiet_sessions AS
19
+ WITH RECURSIVE chain(root_id, id, lastUpdatedAt, status) AS (
20
+ SELECT id, id, lastUpdatedAt, status FROM agent_tasks
21
+ WHERE parentTaskId IS NULL AND requestedByUserId IS NOT NULL
22
+ UNION ALL
23
+ SELECT c.root_id, t.id, t.lastUpdatedAt, t.status
24
+ FROM chain c JOIN agent_tasks t ON t.parentTaskId = c.id
25
+ ), activity AS (
26
+ -- SQLite takes the bare status from the row with MAX(lastUpdatedAt), as
27
+ -- the session list does. Keep MAX the only MIN/MAX aggregate here.
28
+ SELECT root_id, MAX(lastUpdatedAt) AS lastActivityAt, status AS latestStatus,
29
+ SUM(EXISTS (SELECT 1 FROM approval_requests ar
30
+ WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending')) AS pendingApproval
31
+ FROM chain GROUP BY root_id
32
+ ), quiet AS (
33
+ SELECT root_id, lastActivityAt FROM activity
34
+ WHERE lastActivityAt <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
35
+ AND latestStatus IN ('completed', 'failed', 'cancelled', 'superseded')
36
+ AND pendingApproval = 0
37
+ ), viewers AS (
38
+ SELECT r.requestedByUserId AS userId, q.root_id AS rootId, q.lastActivityAt
39
+ FROM quiet q JOIN agent_tasks r ON r.id = q.root_id
40
+ UNION
41
+ SELECT s.userId, q.root_id, q.lastActivityAt
42
+ FROM quiet q JOIN session_shares s ON s.rootTaskId = q.root_id
43
+ WHERE s.revokedAt IS NULL AND s.userId IS NOT NULL
44
+ UNION
45
+ SELECT u.id, q.root_id, q.lastActivityAt
46
+ FROM quiet q JOIN session_shares s ON s.rootTaskId = q.root_id CROSS JOIN users u
47
+ WHERE s.revokedAt IS NULL AND s.userId IS NULL
48
+ )
49
+ SELECT v.userId, v.rootId, v.lastActivityAt
50
+ FROM viewers v JOIN users u ON u.id = v.userId;
51
+
52
+ -- Open rows that read as read: their cursor covers the newest activity.
53
+ UPDATE inbox_item_state
54
+ SET status = 'done',
55
+ doneAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
56
+ lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
57
+ WHERE itemType = 'to_read'
58
+ AND status = 'open'
59
+ AND EXISTS (
60
+ SELECT 1 FROM _quiet_sessions q
61
+ WHERE q.userId = inbox_item_state.userId
62
+ AND q.rootId = inbox_item_state.itemId
63
+ AND q.lastActivityAt <= COALESCE(inbox_item_state.lastReadAt, inbox_item_state.lastUpdatedAt)
64
+ );
65
+
66
+ -- No row yet: quiet for 7 days already reads as read.
67
+ INSERT OR IGNORE INTO inbox_item_state
68
+ (userId, itemType, itemId, status, doneAt, createdAt, lastUpdatedAt)
69
+ SELECT q.userId, 'to_read', q.rootId, 'done',
70
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
71
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
72
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
73
+ FROM _quiet_sessions q;
74
+
75
+ DROP TABLE _quiet_sessions;
@@ -0,0 +1,40 @@
1
+ -- Full-text index for ranked skill search (searchSkills / skill-search tool).
2
+ -- Same shape as memory_fts: an id column FTS5 does not index, the searchable
3
+ -- text columns, and the porter tokenizer. Column order matters: searchSkills
4
+ -- passes bm25() weights positionally (skill_id, name, description, content).
5
+ CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
6
+ skill_id UNINDEXED,
7
+ name,
8
+ description,
9
+ content,
10
+ tokenize='porter unicode61'
11
+ );
12
+
13
+ INSERT INTO skills_fts(skill_id, name, description, content)
14
+ SELECT id, name, description, content FROM skills
15
+ WHERE id NOT IN (SELECT skill_id FROM skills_fts WHERE skill_id IS NOT NULL);
16
+
17
+ -- Triggers keep the index in sync on every write path, including FK cascade
18
+ -- deletes. A later migration that rebuilds `skills` (CREATE new + DROP +
19
+ -- RENAME) drops these triggers with the old table and must recreate them;
20
+ -- src/tests/skill-search.test.ts asserts they exist on the migrated schema.
21
+ CREATE TRIGGER IF NOT EXISTS skills_fts_insert
22
+ AFTER INSERT ON skills
23
+ BEGIN
24
+ INSERT INTO skills_fts(skill_id, name, description, content)
25
+ VALUES (NEW.id, NEW.name, NEW.description, NEW.content);
26
+ END;
27
+
28
+ CREATE TRIGGER IF NOT EXISTS skills_fts_update
29
+ AFTER UPDATE OF id, name, description, content ON skills
30
+ BEGIN
31
+ DELETE FROM skills_fts WHERE skill_id = OLD.id;
32
+ INSERT INTO skills_fts(skill_id, name, description, content)
33
+ VALUES (NEW.id, NEW.name, NEW.description, NEW.content);
34
+ END;
35
+
36
+ CREATE TRIGGER IF NOT EXISTS skills_fts_delete
37
+ AFTER DELETE ON skills
38
+ BEGIN
39
+ DELETE FROM skills_fts WHERE skill_id = OLD.id;
40
+ END;
@@ -0,0 +1,37 @@
1
+ -- Key a child workflow run by its parent step AND that step's attempt.
2
+ --
3
+ -- The retry poller re-executes a failed step under the same step id, with
4
+ -- `retryCount` bumped. Keyed by step id alone, the retried `sub-workflow`
5
+ -- step reconnected to the failed child of the previous attempt and failed
6
+ -- again without starting a new one.
7
+
8
+ ALTER TABLE workflow_runs ADD COLUMN parentStepAttempt INTEGER NOT NULL DEFAULT 0;
9
+
10
+ -- Before this migration a step had at most one child, created on whichever
11
+ -- attempt first got that far (a retry after a synchronous failure, such as a
12
+ -- disabled child workflow, creates it on attempt 1 or later), and every later
13
+ -- attempt reconnected to it. Hand that child to the attempt that settles or
14
+ -- re-executes it now, or recovery would look up the step's current attempt,
15
+ -- find no child and fail the parent as "deleted":
16
+ -- - a step queued for a retry (`failed` with `nextRetryAt`) starts its next
17
+ -- attempt at `retryCount`, which must start a new child, so the existing
18
+ -- child belongs to the previous attempt;
19
+ -- - any other step (running, waiting, terminal) owns it at `retryCount`.
20
+ -- A child whose step is gone keeps attempt 0.
21
+ UPDATE workflow_runs
22
+ SET parentStepAttempt = (
23
+ SELECT CASE
24
+ WHEN s.status = 'failed' AND s.nextRetryAt IS NOT NULL
25
+ THEN MAX(s.retryCount - 1, 0)
26
+ ELSE s.retryCount
27
+ END
28
+ FROM workflow_run_steps s
29
+ WHERE s.id = workflow_runs.parentStepId
30
+ )
31
+ WHERE parentStepId IS NOT NULL
32
+ AND EXISTS (SELECT 1 FROM workflow_run_steps s WHERE s.id = workflow_runs.parentStepId);
33
+
34
+ DROP INDEX IF EXISTS idx_workflow_runs_parentStepId;
35
+
36
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_runs_parentStep_attempt
37
+ ON workflow_runs(parentStepId, parentStepAttempt) WHERE parentStepId IS NOT NULL;
@@ -0,0 +1,39 @@
1
+ -- Name + description index for ranked skill search. skills_fts (239) scores
2
+ -- the whole row, and FTS5 bm25 length-normalizes over every column, so a
3
+ -- long SKILL.md body buries a name or description hit. This index holds only
4
+ -- the short fields; searchSkillRowsRanked adds its score to skills_fts's.
5
+ -- Column order matters: bm25() weights are positional (skill_id, name,
6
+ -- description).
7
+ CREATE VIRTUAL TABLE IF NOT EXISTS skills_meta_fts USING fts5(
8
+ skill_id UNINDEXED,
9
+ name,
10
+ description,
11
+ tokenize='porter unicode61'
12
+ );
13
+
14
+ INSERT INTO skills_meta_fts(skill_id, name, description)
15
+ SELECT id, name, description FROM skills
16
+ WHERE id NOT IN (SELECT skill_id FROM skills_meta_fts WHERE skill_id IS NOT NULL);
17
+
18
+ -- Same sync rules as skills_fts: a later migration that rebuilds `skills`
19
+ -- must recreate these triggers; src/tests/skill-search.test.ts asserts them.
20
+ CREATE TRIGGER IF NOT EXISTS skills_meta_fts_insert
21
+ AFTER INSERT ON skills
22
+ BEGIN
23
+ INSERT INTO skills_meta_fts(skill_id, name, description)
24
+ VALUES (NEW.id, NEW.name, NEW.description);
25
+ END;
26
+
27
+ CREATE TRIGGER IF NOT EXISTS skills_meta_fts_update
28
+ AFTER UPDATE OF id, name, description ON skills
29
+ BEGIN
30
+ DELETE FROM skills_meta_fts WHERE skill_id = OLD.id;
31
+ INSERT INTO skills_meta_fts(skill_id, name, description)
32
+ VALUES (NEW.id, NEW.name, NEW.description);
33
+ END;
34
+
35
+ CREATE TRIGGER IF NOT EXISTS skills_meta_fts_delete
36
+ AFTER DELETE ON skills
37
+ BEGIN
38
+ DELETE FROM skills_meta_fts WHERE skill_id = OLD.id;
39
+ END;
@@ -1,5 +1,6 @@
1
1
  import type { PermissionVerb } from "../rbac";
2
2
  import { PermissionVerbSchema } from "../rbac";
3
+ import { invalidateTelemetryUserRole } from "../telemetry-identity";
3
4
  import { isEnvFlagEnabled } from "../utils/env-flag";
4
5
  import { getDb, getDbClient } from "./db";
5
6
 
@@ -39,8 +40,19 @@ export const BUILTIN_ROLES = [
39
40
  "session.presence",
40
41
  ],
41
42
  },
43
+ {
44
+ id: "rbac-role-viewer",
45
+ name: "viewer",
46
+ description: "Read-only: no task, approval or script writes.",
47
+ isBuiltin: true,
48
+ grantsAll: false,
49
+ verbs: ["favorite.write.own", "session.presence"],
50
+ },
42
51
  ] satisfies (BuiltinRoleDefinition & { isBuiltin: true })[];
43
52
 
53
+ /** A person holds exactly one of these; other roles are extras on top. */
54
+ export const PRIMARY_ROLE_NAMES: readonly string[] = ["admin", "requester", "viewer"];
55
+
44
56
  export type EffectiveGrant = {
45
57
  grantsAll: boolean;
46
58
  verbs: ReadonlySet<PermissionVerb>;
@@ -80,6 +92,8 @@ type RoleIdentityRow = {
80
92
  name: string;
81
93
  };
82
94
 
95
+ type GrantRoleRow = RoleIdentityRow & { grantsAll: number };
96
+
83
97
  type UserRoleRow = {
84
98
  id: string;
85
99
  name: string;
@@ -233,6 +247,130 @@ export async function listUserRoles(userId: string): Promise<UserRole[]> {
233
247
  return rows.map(roleRowToUserRole);
234
248
  }
235
249
 
250
+ export type RbacRoleCatalogEntry = UserRole & { primary: boolean; verbs: string[] };
251
+
252
+ /** Every role with its verbs, for the role picker. */
253
+ export async function listRbacRoles(): Promise<RbacRoleCatalogEntry[]> {
254
+ const client = getDbClient();
255
+ const roles = await client.query<UserRoleRow>(
256
+ "SELECT id, name, description, isBuiltin, grantsAll, createdAt FROM roles ORDER BY name",
257
+ );
258
+ const perms = await client.query<{ roleId: string; verb: string }>(
259
+ "SELECT roleId, verb FROM role_permissions ORDER BY verb",
260
+ );
261
+ return roles.map((row) => ({
262
+ ...roleRowToUserRole(row),
263
+ primary: PRIMARY_ROLE_NAMES.includes(row.name),
264
+ verbs: perms.filter((p) => p.roleId === row.id).map((p) => p.verb),
265
+ }));
266
+ }
267
+
268
+ export class InvalidRoleSetError extends Error {}
269
+
270
+ export class LastAdminError extends Error {
271
+ constructor() {
272
+ super("last_admin");
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Throw LastAdminError when removing this user would leave the swarm without
278
+ * an active grant-all user. Joins the caller's transaction.
279
+ */
280
+ export async function assertNotLastAdmin(userId: string): Promise<void> {
281
+ const client = getDbClient();
282
+ const self = await client.get<{ n: number }>(
283
+ `SELECT COUNT(*) AS n
284
+ FROM users u
285
+ JOIN principal_roles pr ON pr.principalType = 'user' AND pr.principalId = u.id
286
+ JOIN roles r ON r.id = pr.roleId AND r.grantsAll = 1
287
+ WHERE u.status = 'active' AND u.id = ?`,
288
+ [userId],
289
+ );
290
+ if ((self?.n ?? 0) === 0) return;
291
+ const others = await client.get<{ n: number }>(
292
+ `SELECT COUNT(DISTINCT u.id) AS n
293
+ FROM users u
294
+ JOIN principal_roles pr ON pr.principalType = 'user' AND pr.principalId = u.id
295
+ JOIN roles r ON r.id = pr.roleId AND r.grantsAll = 1
296
+ WHERE u.status = 'active' AND u.id <> ?`,
297
+ [userId],
298
+ );
299
+ if ((others?.n ?? 0) === 0) throw new LastAdminError();
300
+ }
301
+
302
+ /**
303
+ * Replace a user's RBAC roles. The set holds exactly one primary role
304
+ * (admin, requester or viewer) plus any extra roles. Refuses to leave the
305
+ * swarm without an active grant-all user.
306
+ */
307
+ export async function setUserRoles(
308
+ userId: string,
309
+ roleNames: string[],
310
+ actorUserId: string | null,
311
+ ): Promise<{ before: string[]; after: string[] }> {
312
+ const names = [...new Set(roleNames)];
313
+ if (names.filter((name) => PRIMARY_ROLE_NAMES.includes(name)).length !== 1) {
314
+ throw new InvalidRoleSetError(
315
+ `Pick exactly one primary role: ${PRIMARY_ROLE_NAMES.join(", ")}`,
316
+ );
317
+ }
318
+ return getDbClient().transaction(async (tx) => {
319
+ const wanted: GrantRoleRow[] = [];
320
+ for (const name of names) {
321
+ const row = await tx.get<GrantRoleRow>(
322
+ "SELECT id, name, grantsAll FROM roles WHERE name = ?",
323
+ [name],
324
+ );
325
+ if (!row) throw new InvalidRoleSetError(`Unknown role: ${name}`);
326
+ wanted.push(row);
327
+ }
328
+ const current = await tx.query<GrantRoleRow>(
329
+ `SELECT r.id, r.name, r.grantsAll
330
+ FROM principal_roles pr JOIN roles r ON r.id = pr.roleId
331
+ WHERE pr.principalType = 'user' AND pr.principalId = ?
332
+ ORDER BY r.name`,
333
+ [userId],
334
+ );
335
+ const wantsAll = wanted.some((r) => r.grantsAll === 1);
336
+ if (current.some((r) => r.grantsAll === 1) && !wantsAll) {
337
+ const user = await tx.get<{ status: string }>("SELECT status FROM users WHERE id = ?", [
338
+ userId,
339
+ ]);
340
+ if (user?.status === "active") {
341
+ const others = await tx.get<{ n: number }>(
342
+ `SELECT COUNT(DISTINCT u.id) AS n
343
+ FROM users u
344
+ JOIN principal_roles pr ON pr.principalType = 'user' AND pr.principalId = u.id
345
+ JOIN roles r ON r.id = pr.roleId AND r.grantsAll = 1
346
+ WHERE u.status = 'active' AND u.id <> ?`,
347
+ [userId],
348
+ );
349
+ if ((others?.n ?? 0) === 0) throw new LastAdminError();
350
+ }
351
+ }
352
+ const wantedIds = new Set(wanted.map((r) => r.id));
353
+ for (const row of current) {
354
+ if (!wantedIds.has(row.id)) {
355
+ await tx.run(
356
+ `DELETE FROM principal_roles
357
+ WHERE principalType = 'user' AND principalId = ? AND roleId = ?`,
358
+ [userId, row.id],
359
+ );
360
+ }
361
+ }
362
+ for (const row of wanted) {
363
+ await tx.run(
364
+ `INSERT OR IGNORE INTO principal_roles (principalType, principalId, roleId, created_by)
365
+ VALUES ('user', ?, ?, ?)`,
366
+ [userId, row.id, actorUserId],
367
+ );
368
+ }
369
+ getDbClient().afterCommit(() => invalidateTelemetryUserRole(userId));
370
+ return { before: current.map((r) => r.name), after: wanted.map((r) => r.name).sort() };
371
+ });
372
+ }
373
+
236
374
  export function ensureRbacSeedsSynced(opts?: { quiet?: boolean }): RbacSeedSyncStats {
237
375
  const db = getDb();
238
376
  const desiredVerbsByRoleId = validatedBuiltinVerbSets();
@@ -0,0 +1,64 @@
1
+ import { type RunScriptInput, type RunScriptOutput, runScript } from "../../scripts-runtime/loader";
2
+ import { getAgentById } from "../db";
3
+ import { mintScriptRunToken, revokeSessionToken } from "../users";
4
+
5
+ /**
6
+ * Backstop lifetime of a script run's api credential. The run revokes its
7
+ * token the moment it ends (see `runScriptWithRunCredential`); this bounds a
8
+ * token whose run died without releasing. Comfortably above the 5m maximum
9
+ * script wall clock.
10
+ */
11
+ const SCRIPT_RUN_TOKEN_TTL_MS = 15 * 60 * 1000;
12
+
13
+ export type ScriptApiCredential = {
14
+ /**
15
+ * The bearer the script's `ctx.swarm` SDK calls present to the api, or
16
+ * `undefined` to let `runScript` fall back to the swarm API key.
17
+ */
18
+ apiKey: string | undefined;
19
+ /** Revoke the per-run token, if one was minted. Safe to call more than once. */
20
+ release: () => Promise<void>;
21
+ };
22
+
23
+ /**
24
+ * Resolve the api credential a script run acts as. When the run has a real
25
+ * executing agent, mint a short-lived token bound to it so `ctx.swarm` calls
26
+ * are scoped to that agent at the api, instead of carrying the swarm API key
27
+ * (the operator). System runs with no real agent (scheduler and workflow runs
28
+ * pass sentinel ids like "schedule"/"workflow") keep the operator key, their
29
+ * existing behavior. The caller must `release()` when the run ends.
30
+ */
31
+ export async function acquireScriptApiCredential(agentId: string): Promise<ScriptApiCredential> {
32
+ if (!(await getAgentById(agentId))) {
33
+ return { apiKey: undefined, release: async () => {} };
34
+ }
35
+ const token = await mintScriptRunToken(agentId, SCRIPT_RUN_TOKEN_TTL_MS);
36
+ let released = false;
37
+ return {
38
+ apiKey: token.plaintext,
39
+ release: async () => {
40
+ if (released) return;
41
+ released = true;
42
+ await revokeSessionToken(token.tokenId);
43
+ },
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Run a script with a per-run credential bound to its executing agent, and
49
+ * revoke that credential when the run ends: on success, a thrown error, a
50
+ * timeout or an abort. Every api-side script-run caller goes through here.
51
+ * The mint and revoke stay on the api side because `src/scripts-runtime/` is
52
+ * a no-DB zone (`scripts/check-db-boundary.sh`); `runScript` only receives the
53
+ * bearer.
54
+ */
55
+ export async function runScriptWithRunCredential(
56
+ input: Omit<RunScriptInput, "apiKey">,
57
+ ): Promise<RunScriptOutput> {
58
+ const credential = await acquireScriptApiCredential(input.agentId);
59
+ try {
60
+ return await runScript({ ...input, apiKey: credential.apiKey });
61
+ } finally {
62
+ await credential.release();
63
+ }
64
+ }
@@ -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,
@@ -628,6 +628,29 @@ export function validateConfigValue(key: string, value: unknown): string | null
628
628
  return validator ? validator(value) : null;
629
629
  }
630
630
 
631
+ /**
632
+ * Every check `PUT /api/config` runs before a write: scope and scopeId shape,
633
+ * reserved and internal keys, the per-key value validator and the env-export
634
+ * rule. Shared with HITL `config` questions so the two writers cannot drift.
635
+ */
636
+ export function validateConfigEntry(entry: {
637
+ scope: "global" | "agent" | "repo";
638
+ scopeId?: string | null;
639
+ key: string;
640
+ value: unknown;
641
+ exportToEnv?: boolean;
642
+ }): string | null {
643
+ if (entry.scope === "global" && entry.scopeId) return "Global scope must not have scopeId";
644
+ if ((entry.scope === "agent" || entry.scope === "repo") && !entry.scopeId) {
645
+ return "Agent/repo scope requires scopeId";
646
+ }
647
+ if (isReservedConfigKey(entry.key)) return reservedKeyError(entry.key).message;
648
+ if (isInternalConfigKey(entry.key)) return internalConfigKeyError(entry.key).message;
649
+ return (
650
+ validateConfigValue(entry.key, entry.value) ?? validateExportToEnv(entry.key, entry.exportToEnv)
651
+ );
652
+ }
653
+
631
654
  /**
632
655
  * Rejects `exportToEnv: true` for keys only the API server reads. Omitting the
633
656
  * flag is fine: the write path stores false for those keys.