@desplega.ai/agent-swarm 2.3.0 → 2.4.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 (247) hide show
  1. package/README.md +1 -0
  2. package/dist/{acp-adapter-v85a2bqy.js → acp-adapter-kedstxx6.js} +6 -6
  3. package/dist/{actions-zgcxfspj.js → actions-vget6r1k.js} +18 -16
  4. package/dist/{amp-adapter-jw9xfz42.js → amp-adapter-xeb5mktv.js} +2 -2
  5. package/dist/{app-4e1s4675.js → app-mqap8t8m.js} +3 -3
  6. package/dist/{assistant-v238n9dk.js → assistant-h0gv97mv.js} +20 -18
  7. package/dist/{boot-reembed-p666cw5n.js → boot-reembed-qr8a894z.js} +1 -1
  8. package/dist/{boot-reembed-w6pm64j4.js → boot-reembed-rj85jjhw.js} +1 -1
  9. package/dist/{boot-scrub-sweep-jcyjagn3.js → boot-scrub-sweep-eyfvn6v9.js} +3 -3
  10. package/dist/{boot-seeds-gbedmamp.js → boot-seeds-xs9a8dx1.js} +4 -4
  11. package/dist/{claude-adapter-q7wspzt8.js → claude-adapter-bm23cye7.js} +5 -5
  12. package/dist/{claude-managed-adapter-n7ahjyx6.js → claude-managed-adapter-1mwrsm9d.js} +2 -2
  13. package/dist/{claude-managed-setup-wza784fb.js → claude-managed-setup-gkfgks4j.js} +1 -1
  14. package/dist/{claude-sdk-session-yf400ptr.js → claude-sdk-session-apb4ypz3.js} +5 -5
  15. package/dist/cli-02vhhssk.js +848 -0
  16. package/dist/{cli-4ync7m60.js → cli-0ebey9hg.js} +1 -1
  17. package/dist/{cli-665j3feb.js → cli-0xk505e8.js} +1 -1
  18. package/dist/{cli-30m5q8mc.js → cli-1cevz46c.js} +5 -5
  19. package/dist/{cli-gzvb3d4a.js → cli-1jx0srjt.js} +3 -3
  20. package/dist/{cli-zj4d6afs.js → cli-1pa3rz69.js} +21 -6
  21. package/dist/{cli-fhvqt2fp.js → cli-2rwet3r7.js} +1 -1
  22. package/dist/{cli-azs9bhc5.js → cli-3wjs22x3.js} +2 -2
  23. package/dist/{cli-dxwh4j32.js → cli-4fhyq711.js} +45 -36
  24. package/dist/{cli-yv3n83x8.js → cli-5ff2sjyt.js} +1 -1
  25. package/dist/{cli-6vcyw6v7.js → cli-5s5hx57y.js} +5 -3
  26. package/dist/{cli-hz64bcnj.js → cli-6ftrrwm1.js} +9 -371
  27. package/dist/{cli-zes4tsbb.js → cli-8b9wvjps.js} +1 -1
  28. package/dist/cli-8cxkw1f5.js +382 -0
  29. package/dist/{cli-4s1kqrqg.js → cli-8rwvksbc.js} +2 -2
  30. package/dist/{cli-e09hj50q.js → cli-8wkb09q5.js} +11 -11
  31. package/dist/{cli-yv0vgscc.js → cli-a1esmmee.js} +13 -2
  32. package/dist/{cli-4hmaheb6.js → cli-avmqhatn.js} +7 -7
  33. package/dist/{cli-g6p7c2my.js → cli-b80a1100.js} +1 -1
  34. package/dist/{cli-tr24eeny.js → cli-bfc3k1x5.js} +3 -3
  35. package/dist/{cli-ka01m5rb.js → cli-bhwa8rzc.js} +13 -11
  36. package/dist/{cli-8cvvytad.js → cli-bn26xbcs.js} +2 -2
  37. package/dist/{cli-k28p6xh7.js → cli-c8fwpgqb.js} +1 -1
  38. package/dist/{cli-rc3r0bnh.js → cli-ccn0eybf.js} +1 -1
  39. package/dist/{cli-9td5ss65.js → cli-dsgj3e7a.js} +2 -2
  40. package/dist/{cli-a17tzmn0.js → cli-ecshf504.js} +2 -2
  41. package/dist/{cli-d43xqzbe.js → cli-eqgfs202.js} +1 -1
  42. package/dist/{cli-ppapy34w.js → cli-f3nx1h8z.js} +2 -2
  43. package/dist/{cli-ea7chd26.js → cli-ffbrkh1r.js} +74 -35
  44. package/dist/{cli-xgn4rwyv.js → cli-kk2r80n5.js} +4 -4
  45. package/dist/{cli-hn3pftj2.js → cli-kz116kvw.js} +3 -3
  46. package/dist/{cli-1v8w782f.js → cli-mvg2hf8p.js} +4 -4
  47. package/dist/{cli-5d1m1qca.js → cli-mz1ezzzh.js} +1 -1
  48. package/dist/{cli-jhmjwqcg.js → cli-nme7wery.js} +6 -6
  49. package/dist/{cli-65q70v2v.js → cli-nrrg0fvy.js} +18 -4
  50. package/dist/{cli-8jtrekpy.js → cli-p1m2b6gc.js} +7 -7
  51. package/dist/{cli-re4wkbzx.js → cli-rg7ntypm.js} +328 -47
  52. package/dist/{cli-fp4xbw07.js → cli-rsd7ga7v.js} +10 -8
  53. package/dist/{cli-etvn5ej2.js → cli-sppc0cmw.js} +1 -1
  54. package/dist/{cli-qrwp40st.js → cli-tg9ksbn8.js} +1 -1
  55. package/dist/{cli-6p7ddg72.js → cli-tgvpmnwz.js} +2 -2
  56. package/dist/{cli-q1812jbw.js → cli-tj4j2ts4.js} +6 -6
  57. package/dist/{cli-kqypty34.js → cli-ttaz6c7m.js} +2 -2
  58. package/dist/{cli-w7z94e6c.js → cli-tx00c0h6.js} +4 -2
  59. package/dist/{cli-4g629sgr.js → cli-wrv09v7q.js} +20 -1
  60. package/dist/{cli-cby329ny.js → cli-wyrr38xm.js} +2 -2
  61. package/dist/{cli-y3dvz6ge.js → cli-x1p5dhq2.js} +3 -3
  62. package/dist/{cli-8qsx817b.js → cli-x719j3vs.js} +219 -629
  63. package/dist/{cli-9e9kjqsj.js → cli-xfe63zx3.js} +1 -1
  64. package/dist/{cli-wtg6edvd.js → cli-ywyrffff.js} +1 -1
  65. package/dist/{cli-0rh20bp3.js → cli-zvze9rbm.js} +1 -1
  66. package/dist/cli.js +14 -14
  67. package/dist/{codex-adapter-nhjj2veb.js → codex-adapter-pp2gq8gr.js} +3 -3
  68. package/dist/{codex-hook-x6tm6y5m.js → codex-hook-qwzccd64.js} +1 -1
  69. package/dist/{codex-session-runner-6hggrjbx.js → codex-session-runner-0vzn09t8.js} +3 -3
  70. package/dist/{commands-mrw1ph2w.js → commands-psqhkttb.js} +2 -2
  71. package/dist/{controller-pt1zam5w.js → controller-t20vz2k8.js} +2 -2
  72. package/dist/{controllers-connect-ymw5541d.js → controllers-connect-gkxmqw9d.js} +24 -20
  73. package/dist/{cursor-adapter-cf4zh5x0.js → cursor-adapter-3v542qe5.js} +1 -1
  74. package/dist/{db-hy80219q.js → db-2xbyenjr.js} +2 -2
  75. package/dist/{db-hhpy6m4x.js → db-nc1rd869.js} +3 -2
  76. package/dist/{dispatcher-6pdjtkr1.js → dispatcher-ewceb8ts.js} +17 -15
  77. package/dist/{dsh-adapter-fbx06re2.js → dsh-adapter-75xwh78t.js} +1 -1
  78. package/dist/{easter-egg-3d-6hg2r942.js → easter-egg-3d-xsc47jwf.js} +1 -1
  79. package/dist/{execute-bcqc69a5.js → execute-gzmw1mnw.js} +1 -1
  80. package/dist/{external-channel-j6vb6ya1.js → external-channel-a5dsmxn4.js} +2 -2
  81. package/dist/{google-generative-ai-phpmwxvg.js → google-generative-ai-bmq8gkf3.js} +1 -1
  82. package/dist/{google-vertex-es9ybgdy.js → google-vertex-b1rvp52s.js} +1 -1
  83. package/dist/{grok-adapter-1193sprp.js → grok-adapter-vj88xr23.js} +7 -7
  84. package/dist/handlers-zbs90bny.js +39 -0
  85. package/dist/{heartbeat-h1w1ktce.js → heartbeat-t2m9r3wa.js} +20 -18
  86. package/dist/{hook-skrbja18.js → hook-8femx1bg.js} +4 -4
  87. package/dist/{hook-ejja4bpe.js → hook-xa677f8r.js} +5 -5
  88. package/dist/{http-mzh41mfj.js → http-xpmmq028.js} +868 -253
  89. package/dist/{index-vjn3wbzf.js → index-8pwc1jc8.js} +17 -15
  90. package/dist/{index-epr2gnbw.js → index-cdzyfrsp.js} +1 -1
  91. package/dist/{index-xmmkyx82.js → index-dg2tmj99.js} +17 -15
  92. package/dist/index-nxa6me0q.js +41 -0
  93. package/dist/{index-6rmnn89d.js → index-s7ckdajq.js} +9 -9
  94. package/dist/{index-wgsw9xcz.js → index-syztqdt6.js} +1 -1
  95. package/dist/{index-x0q2a4f9.js → index-ysd45kgc.js} +1 -1
  96. package/dist/{index-g909y089.js → index-zgvqzjsw.js} +8 -8
  97. package/dist/{keepalive-4djvbm2j.js → keepalive-3nr4st3r.js} +3 -3
  98. package/dist/{lead-61kqp604.js → lead-td8h2sfn.js} +33 -31
  99. package/dist/{maintenance-f3xhnjsm.js → maintenance-verwpe2c.js} +1 -1
  100. package/dist/{oauth-refresh-sweep-fpd6x6rw.js → oauth-refresh-sweep-9q2t0y3q.js} +2 -2
  101. package/dist/{onboard-196tjgyn.js → onboard-aytqedmq.js} +2 -2
  102. package/dist/{opencode-adapter-eeesgfqk.js → opencode-adapter-f4hryjny.js} +4 -4
  103. package/dist/{otel-impl-g8fh4c21.js → otel-impl-hckw4wt1.js} +1 -1
  104. package/dist/{pi-mono-adapter-zasp3ze3.js → pi-mono-adapter-hxby0ce1.js} +6 -6
  105. package/dist/{pricing-refresh-sdcf5yaz.js → pricing-refresh-5nvme3sm.js} +3 -3
  106. package/dist/{rbac-roles-bsp5657v.js → rbac-roles-pd8c26m1.js} +5 -3
  107. package/dist/{rbac-roles-2x4g1wb5.js → rbac-roles-vbpy21mg.js} +2 -2
  108. package/dist/{render-v2-0pyb39bb.js → render-v2-7nqgvk2q.js} +3 -3
  109. package/dist/{resume-a86tkj6r.js → resume-b6djcj3c.js} +6 -6
  110. package/dist/{runtime-ar8310ba.js → runtime-txrxc9za.js} +1 -1
  111. package/dist/session-access-fc3f9y12.js +25 -0
  112. package/dist/{setup-ypbzcapd.js → setup-n39szm6b.js} +1 -1
  113. package/dist/{virtual-modules-wzk6jf3a.js → virtual-modules-wyp2k6z3.js} +2 -2
  114. package/dist/{work-objects-yjp8de12.js → work-objects-jem2fre0.js} +20 -18
  115. package/dist/{worker-4ptnn1ge.js → worker-wr6xgfr2.js} +33 -31
  116. package/openapi.json +844 -110
  117. package/package.json +1 -1
  118. package/src/apps/action-caller.ts +6 -0
  119. package/src/apps/script-types.ts +16 -3
  120. package/src/be/db/active-sessions.ts +5 -0
  121. package/src/be/db/session-visibility.ts +29 -0
  122. package/src/be/db/tasks/read.ts +61 -3
  123. package/src/be/db/tasks/write.ts +15 -6
  124. package/src/be/db.ts +92 -33
  125. package/src/be/events.ts +11 -0
  126. package/src/be/migrations/230_session_shares.sql +23 -0
  127. package/src/be/rbac-roles.ts +2 -0
  128. package/src/be/session-shares.ts +244 -0
  129. package/src/be/swarm-config-guard.ts +23 -2
  130. package/src/commands/runner.ts +11 -0
  131. package/src/http/active-sessions.ts +13 -2
  132. package/src/http/agents.ts +10 -3
  133. package/src/http/all-routes.ts +1 -0
  134. package/src/http/api-keys.ts +3 -1
  135. package/src/http/approval-requests.ts +2 -1
  136. package/src/http/apps.ts +36 -11
  137. package/src/http/config.ts +3 -0
  138. package/src/http/context.ts +2 -0
  139. package/src/http/core.ts +39 -2
  140. package/src/http/events.ts +6 -2
  141. package/src/http/favorite-owner.ts +1 -1
  142. package/src/http/fs.ts +38 -3
  143. package/src/http/index.ts +3 -1
  144. package/src/http/memory.ts +3 -1
  145. package/src/http/ranking.ts +367 -0
  146. package/src/http/realtime.ts +26 -21
  147. package/src/http/route-def.ts +77 -11
  148. package/src/http/script-runs.ts +2 -1
  149. package/src/http/scripts.ts +8 -0
  150. package/src/http/session-access.ts +179 -0
  151. package/src/http/session-data.ts +10 -2
  152. package/src/http/sessions.ts +212 -12
  153. package/src/http/stats.ts +7 -3
  154. package/src/http/tasks.ts +83 -7
  155. package/src/http/used-by.ts +11 -3
  156. package/src/prompts/session-templates.ts +3 -1
  157. package/src/providers/amp-adapter.ts +1 -1
  158. package/src/ranking/telemetry-ranking.ts +147 -0
  159. package/src/ranking/usage.ts +57 -0
  160. package/src/rbac/admission.ts +22 -7
  161. package/src/rbac/legacy-policy.ts +2 -0
  162. package/src/rbac/permissions.ts +9 -0
  163. package/src/realtime/auth.ts +69 -0
  164. package/src/realtime/tickets.ts +4 -1
  165. package/src/realtime/transport.ts +77 -9
  166. package/src/slack/blocks.ts +18 -0
  167. package/src/slack/dm.ts +29 -0
  168. package/src/telemetry-contract/EVENTS.md +3 -0
  169. package/src/telemetry-contract/SOURCE.json +37 -4
  170. package/src/telemetry-contract/catalog.json +69 -0
  171. package/src/telemetry-contract/ranking/fixtures/expected.json +30 -0
  172. package/src/telemetry-contract/ranking/fixtures/invalid/bad-category.json +11 -0
  173. package/src/telemetry-contract/ranking/fixtures/invalid/bad-claim.json +11 -0
  174. package/src/telemetry-contract/ranking/fixtures/invalid/blank-description.json +11 -0
  175. package/src/telemetry-contract/ranking/fixtures/invalid/blank-name.json +11 -0
  176. package/src/telemetry-contract/ranking/fixtures/invalid/description-too-long.json +11 -0
  177. package/src/telemetry-contract/ranking/fixtures/invalid/domain-uppercase.json +11 -0
  178. package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-scheme.json +11 -0
  179. package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-www.json +11 -0
  180. package/src/telemetry-contract/ranking/fixtures/invalid/http-source-url.json +11 -0
  181. package/src/telemetry-contract/ranking/fixtures/invalid/missing-description.json +10 -0
  182. package/src/telemetry-contract/ranking/fixtures/invalid/missing-version.json +10 -0
  183. package/src/telemetry-contract/ranking/fixtures/invalid/name-not-string.json +11 -0
  184. package/src/telemetry-contract/ranking/fixtures/invalid/name-too-long.json +11 -0
  185. package/src/telemetry-contract/ranking/fixtures/invalid/not-object.json +1 -0
  186. package/src/telemetry-contract/ranking/fixtures/invalid/null.json +1 -0
  187. package/src/telemetry-contract/ranking/fixtures/invalid/other-with-subcategory.json +11 -0
  188. package/src/telemetry-contract/ranking/fixtures/invalid/oversize.json +1 -0
  189. package/src/telemetry-contract/ranking/fixtures/invalid/short-claim.json +11 -0
  190. package/src/telemetry-contract/ranking/fixtures/invalid/subcategory-other-category.json +11 -0
  191. package/src/telemetry-contract/ranking/fixtures/invalid/trailing-data.json +1 -0
  192. package/src/telemetry-contract/ranking/fixtures/invalid/unknown-field.json +12 -0
  193. package/src/telemetry-contract/ranking/fixtures/invalid/unsupported-version.json +11 -0
  194. package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-not-date.json +11 -0
  195. package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-offset.json +11 -0
  196. package/src/telemetry-contract/ranking/fixtures/invalid/wrong-case-field.json +11 -0
  197. package/src/telemetry-contract/ranking/fixtures/valid/category-without-subcategory.json +11 -0
  198. package/src/telemetry-contract/ranking/fixtures/valid/full.json +11 -0
  199. package/src/telemetry-contract/ranking/fixtures/valid/minimal.json +11 -0
  200. package/src/telemetry-contract/ranking/fixtures/valid/padded-text.json +11 -0
  201. package/src/telemetry-contract/ranking/fixtures/valid/unicode-name.json +11 -0
  202. package/src/telemetry-contract/ranking/listing.v1.schema.json +61 -0
  203. package/src/telemetry-contract/ranking/taxonomy.json +8 -0
  204. package/src/telemetry-contract/ranking-taxonomy.gen.ts +11 -0
  205. package/src/telemetry-contract/types.gen.ts +708 -1
  206. package/src/telemetry.ts +26 -2
  207. package/src/tests/apps-rbac.test.ts +189 -2
  208. package/src/tests/apps-script-types.test.ts +16 -3
  209. package/src/tests/check-pr-body.test.ts +23 -0
  210. package/src/tests/check-ranking-entry.test.ts +146 -0
  211. package/src/tests/db-extraction-contract.test.ts +1 -0
  212. package/src/tests/db-task-read-contract.test.ts +1 -0
  213. package/src/tests/docs-index-footprint.test.js +2 -2
  214. package/src/tests/org-identity.test.ts +44 -0
  215. package/src/tests/ranking-listing.test.ts +112 -0
  216. package/src/tests/ranking-route.test.ts +319 -0
  217. package/src/tests/ranking-usage.test.ts +50 -0
  218. package/src/tests/rbac-admission-e2e.test.ts +8 -0
  219. package/src/tests/rbac-admission.test.ts +38 -0
  220. package/src/tests/rbac-charact-http.test.ts +4 -3
  221. package/src/tests/rbac-engine.test.ts +2 -0
  222. package/src/tests/rbac-roles.test.ts +2 -0
  223. package/src/tests/realtime-room-surfaces.test.ts +3 -1
  224. package/src/tests/realtime-session-join-race.test.ts +142 -0
  225. package/src/tests/realtime-tickets.test.ts +24 -17
  226. package/src/tests/realtime-transport.test.ts +91 -0
  227. package/src/tests/session-route-posture.test.ts +639 -0
  228. package/src/tests/session-sharing-routes.test.ts +282 -0
  229. package/src/tests/session-sharing.test.ts +526 -0
  230. package/src/tests/sessions-detail-batch.test.ts +7 -1
  231. package/src/tests/slack-tool-blocks.test.ts +101 -0
  232. package/src/tests/slack-update.test.ts +55 -0
  233. package/src/tests/slack-user-dm.test.ts +177 -0
  234. package/src/tests/used-by-route.test.ts +10 -0
  235. package/src/tools/slack-post.ts +18 -12
  236. package/src/tools/slack-read.ts +13 -1
  237. package/src/tools/slack-reply.ts +7 -10
  238. package/src/tools/slack-start-thread.ts +18 -11
  239. package/src/tools/slack-update.ts +2 -9
  240. package/src/types.ts +35 -0
  241. package/src/utils/org-identity.ts +33 -0
  242. package/templates/skills/apps/SKILL.md +17 -0
  243. package/templates/skills/apps/content.md +17 -0
  244. package/dist/handlers-tb3xgjnm.js +0 -37
  245. package/dist/index-nyfcdpky.js +0 -39
  246. package/dist/{cli-n5mrsg0f.js → cli-60cgh1rv.js} +3 -3
  247. /package/dist/{cli-j0v75y13.js → cli-y83zk5sb.js} +0 -0
@@ -7,18 +7,40 @@ import {
7
7
  getTaskById,
8
8
  listRecentSessions,
9
9
  } from "../be/db";
10
+ import {
11
+ getSessionRoot,
12
+ listActiveSessionShares,
13
+ type SessionAccess,
14
+ sessionAccessForRoot,
15
+ setSessionShares,
16
+ toSessionSharesState,
17
+ } from "../be/session-shares";
10
18
  import { getTaskSteeringFieldsForTasks } from "../be/steering";
11
19
  import { getTaskCitationsForTasks, TaskCitationSchema } from "../be/task-citations";
12
20
  import {
21
+ findUserById,
13
22
  InvalidRuntimeTokenError,
14
23
  mintRuntimeToken,
15
24
  mintSessionToken,
16
25
  revokeSessionToken,
17
26
  } from "../be/users";
18
- import { type AgentTask, AgentTaskSchema, AgentTaskStatusSchema, SteerModeSchema } from "../types";
27
+ import { can } from "../rbac";
28
+ import { sessionAccessTopic } from "../realtime/auth";
29
+ import { realtimeBus } from "../realtime/bus";
30
+ import {
31
+ type AgentTask,
32
+ AgentTaskSchema,
33
+ AgentTaskStatusSchema,
34
+ SessionAccessSchema,
35
+ SessionOwnerSchema,
36
+ SessionSharesSchema,
37
+ SessionSharesUpdateSchema,
38
+ SteerModeSchema,
39
+ } from "../types";
19
40
  import { getRequestAuth } from "../utils/request-auth-context";
20
41
  import { isRuntimePoolsEnabled } from "../utils/runtime-pools-flag";
21
42
  import { route } from "./route-def";
43
+ import { resolveSessionActor, type SessionActor } from "./session-access";
22
44
  import { jsonError } from "./utils";
23
45
 
24
46
  // ─── Response Schemas ────────────────────────────────────────────────────────
@@ -91,6 +113,9 @@ const mintSessionTokenRoute = route({
91
113
  method: "post",
92
114
  path: "/api/sessions/tokens",
93
115
  pattern: ["api", "sessions", "tokens"],
116
+ session: {
117
+ none: "user tokens cannot mint: the handler admits only the operator key or the task's own runtime",
118
+ },
94
119
  summary: "Mint an ephemeral task or runtime token",
95
120
  description:
96
121
  "Operators can mint task or runtime tokens. Runtime tokens can mint task tokens for their own assigned tasks. Child tokens expire with their parent runtime.",
@@ -175,6 +200,14 @@ const listSessions = route({
175
200
  contextKeyPrefix: z.string().min(1).optional(),
176
201
  /** `full` restores the legacy shape (full root `AgentTask`); default is slim. */
177
202
  fields: z.enum(["full", "slim"]).optional(),
203
+ /**
204
+ * `mine`: sessions the caller owns. `shared`: sessions someone else owns
205
+ * with an active share to the caller or to everyone. Omit for every
206
+ * session the caller can open. Callers that act for no user (agents, the
207
+ * operator key with no picked dashboard user) see every session, and
208
+ * an empty list for either scope.
209
+ */
210
+ scope: z.enum(["mine", "shared"]).optional(),
178
211
  }),
179
212
  responses: {
180
213
  200: {
@@ -195,23 +228,78 @@ const getSession = route({
195
228
  method: "get",
196
229
  path: "/api/sessions/{rootTaskId}",
197
230
  pattern: ["api", "sessions", null],
231
+ session: { taskParam: "rootTaskId" },
198
232
  summary: "Get a session — root task + the entire descendant chain",
199
233
  tags: ["Sessions"],
200
234
  params: z.object({ rootTaskId: z.string() }),
201
235
  responses: {
202
236
  200: {
203
- description: "Root task + chain (ordered by createdAt)",
237
+ description:
238
+ "Root task + chain (ordered by createdAt), plus the caller's `access` and the session `owner`",
204
239
  schema: z.object({
205
240
  root: TaskWithSteeringSchema,
206
241
  chain: z.array(TaskWithSteeringSchema),
242
+ access: SessionAccessSchema,
243
+ owner: SessionOwnerSchema.nullable(),
207
244
  }),
208
245
  },
209
246
  401: { description: "Unauthorized" },
247
+ 403: { description: "The caller has no access to this session" },
248
+ 404: { description: "Root task not found" },
249
+ },
250
+ auth: { apiKey: true },
251
+ });
252
+
253
+ const getSessionShares = route({
254
+ method: "get",
255
+ path: "/api/sessions/{rootTaskId}/shares",
256
+ pattern: ["api", "sessions", null, "shares"],
257
+ session: { taskParam: "rootTaskId" },
258
+ summary: "List a session's active shares",
259
+ description:
260
+ "Active shares only. `everyone` is the access every user has; `users` are per-person shares. A session with no shares is private to its owner.",
261
+ tags: ["Sessions"],
262
+ params: z.object({ rootTaskId: z.string() }),
263
+ responses: {
264
+ 200: { description: "Active shares", schema: SessionSharesSchema },
265
+ 401: { description: "Unauthorized" },
266
+ 403: { description: "The caller has no access to this session" },
267
+ 404: { description: "Root task not found" },
268
+ },
269
+ auth: { apiKey: true },
270
+ });
271
+
272
+ const putSessionShares = route({
273
+ method: "put",
274
+ path: "/api/sessions/{rootTaskId}/shares",
275
+ pattern: ["api", "sessions", null, "shares"],
276
+ session: { taskParam: "rootTaskId" },
277
+ summary: "Set who can view or edit a session",
278
+ description:
279
+ "Owner only. The body is the full desired state: users left out lose access. Changed targets have their active share revoked and a new one inserted in one transaction; shares are never deleted.",
280
+ tags: ["Sessions"],
281
+ params: z.object({ rootTaskId: z.string() }),
282
+ body: SessionSharesUpdateSchema,
283
+ responses: {
284
+ 200: { description: "Active shares after the change", schema: SessionSharesSchema },
285
+ 400: { description: "Validation error or unknown user" },
286
+ 401: { description: "Unauthorized" },
287
+ 403: { description: "Only the session owner can share it" },
210
288
  404: { description: "Root task not found" },
289
+ 409: { description: "The session has no owner, so it is already open to everyone" },
211
290
  },
212
291
  auth: { apiKey: true },
292
+ rbac: { permission: "session.share" },
213
293
  });
214
294
 
295
+ /** The actor's access to the session rooted at `root`; `owner` when the actor bypasses checks. */
296
+ async function accessForRoot(
297
+ actor: SessionActor,
298
+ root: { id: string; requestedByUserId: string | null },
299
+ ): Promise<SessionAccess> {
300
+ return actor.kind === "bypass" ? "owner" : sessionAccessForRoot(actor.userId, root);
301
+ }
302
+
215
303
  // ─── Handler ─────────────────────────────────────────────────────────────────
216
304
 
217
305
  export async function handleSessions(
@@ -219,6 +307,7 @@ export async function handleSessions(
219
307
  res: ServerResponse,
220
308
  pathSegments: string[],
221
309
  queryParams: URLSearchParams,
310
+ myAgentId?: string,
222
311
  ): Promise<boolean> {
223
312
  if (mintSessionTokenRoute.match(req.method, pathSegments)) {
224
313
  const auth = getRequestAuth(req);
@@ -312,13 +401,30 @@ export async function handleSessions(
312
401
  .map((s) => s.trim())
313
402
  .filter(Boolean)
314
403
  : undefined;
315
- const baseOpts = {
316
- limit: parsed.query.limit,
317
- offset: parsed.query.offset,
404
+ const actor = await resolveSessionActor(req, myAgentId);
405
+ if (actor.kind === "bypass" && parsed.query.scope) {
406
+ // No user to own or receive a share.
407
+ listSessions.respond(res, 200, {
408
+ sessions: [],
409
+ total: 0,
410
+ limit: parsed.query.limit ?? 25,
411
+ offset: parsed.query.offset ?? 0,
412
+ });
413
+ return true;
414
+ }
415
+ const filterOpts = {
318
416
  source: sources,
319
417
  q: parsed.query.q,
320
418
  requestedByUserId: parsed.query.requestedByUserId,
321
419
  contextKeyPrefix: parsed.query.contextKeyPrefix,
420
+ ...(actor.kind === "user"
421
+ ? { viewer: { userId: actor.userId, scope: parsed.query.scope ?? "all" } as const }
422
+ : {}),
423
+ };
424
+ const baseOpts = {
425
+ ...filterOpts,
426
+ limit: parsed.query.limit,
427
+ offset: parsed.query.offset,
322
428
  };
323
429
  // List responses default to slim (root is a task summary); `?fields=full` restores it.
324
430
  const sessions =
@@ -327,12 +433,7 @@ export async function handleSessions(
327
433
  : await listRecentSessions({ ...baseOpts, slim: true });
328
434
  // Filter-aware total: same WHERE as the list query, so the UI pager
329
435
  // reflects the filtered result set.
330
- const total = await countSessions({
331
- source: sources,
332
- q: parsed.query.q,
333
- requestedByUserId: parsed.query.requestedByUserId,
334
- contextKeyPrefix: parsed.query.contextKeyPrefix,
335
- });
436
+ const total = await countSessions(filterOpts);
336
437
  listSessions.respond(res, 200, {
337
438
  sessions,
338
439
  total,
@@ -350,6 +451,25 @@ export async function handleSessions(
350
451
  jsonError(res, "Root task not found", 404);
351
452
  return true;
352
453
  }
454
+ // The route accepts any task id; access follows the topmost root.
455
+ const sessionRoot = (await getSessionRoot(root.id)) ?? {
456
+ id: root.id,
457
+ requestedByUserId: root.requestedByUserId ?? null,
458
+ };
459
+ const access = await accessForRoot(await resolveSessionActor(req, myAgentId), sessionRoot);
460
+ if (access === "none") {
461
+ jsonError(res, "You don't have access to this session. Ask the owner to share it.", 403);
462
+ return true;
463
+ }
464
+ const ownerUser = sessionRoot.requestedByUserId
465
+ ? await findUserById(sessionRoot.requestedByUserId)
466
+ : null;
467
+ const owner = sessionRoot.requestedByUserId
468
+ ? {
469
+ id: sessionRoot.requestedByUserId,
470
+ name: ownerUser?.name ?? sessionRoot.requestedByUserId,
471
+ }
472
+ : null;
353
473
  const chain = await getRootTaskChain(parsed.params.rootTaskId);
354
474
  // Fixed query count regardless of chain length: one agents lookup and one
355
475
  // citations lookup for the whole chain (chunked past 500 ids).
@@ -363,7 +483,87 @@ export async function handleSessions(
363
483
  ...steering.get(task.id)!,
364
484
  citations: citations.get(task.id)!,
365
485
  });
366
- getSession.respond(res, 200, { root: decorate(root), chain: chain.map(decorate) });
486
+ getSession.respond(res, 200, {
487
+ root: decorate(root),
488
+ chain: chain.map(decorate),
489
+ access,
490
+ owner,
491
+ });
492
+ return true;
493
+ }
494
+
495
+ if (getSessionShares.match(req.method, pathSegments)) {
496
+ const parsed = await getSessionShares.parse(req, res, pathSegments, queryParams);
497
+ if (!parsed) return true;
498
+ const root = await getSessionRoot(parsed.params.rootTaskId);
499
+ if (!root) {
500
+ jsonError(res, "Root task not found", 404);
501
+ return true;
502
+ }
503
+ const access = await accessForRoot(await resolveSessionActor(req, myAgentId), root);
504
+ if (access === "none") {
505
+ jsonError(res, "You don't have access to this session. Ask the owner to share it.", 403);
506
+ return true;
507
+ }
508
+ getSessionShares.respond(
509
+ res,
510
+ 200,
511
+ toSessionSharesState(await listActiveSessionShares(root.id)),
512
+ );
513
+ return true;
514
+ }
515
+
516
+ if (putSessionShares.match(req.method, pathSegments)) {
517
+ const parsed = await putSessionShares.parse(req, res, pathSegments, queryParams);
518
+ if (!parsed) return true;
519
+ const auth = getRequestAuth(req);
520
+ const actor = await resolveSessionActor(req, myAgentId);
521
+ // Sharing is a dashboard action: a person, or the operator key itself.
522
+ const principal =
523
+ auth?.kind === "user"
524
+ ? ({ kind: "user", userId: auth.userId } as const)
525
+ : auth?.kind === "operator" && !myAgentId
526
+ ? ({ kind: "operator" } as const)
527
+ : null;
528
+ if (
529
+ !principal ||
530
+ !can({ principal, verb: "session.share", resource: { kind: "none" }, source: "http" }).allow
531
+ ) {
532
+ jsonError(res, "Only the session owner can share it", 403);
533
+ return true;
534
+ }
535
+ const root = await getSessionRoot(parsed.params.rootTaskId);
536
+ if (!root) {
537
+ jsonError(res, "Root task not found", 404);
538
+ return true;
539
+ }
540
+ if (!root.requestedByUserId) {
541
+ jsonError(res, "This session has no owner, so it is already open to everyone", 409);
542
+ return true;
543
+ }
544
+ if (actor.kind === "user" && actor.userId !== root.requestedByUserId) {
545
+ jsonError(res, "Only the session owner can share it", 403);
546
+ return true;
547
+ }
548
+ const userIds = [...new Set(parsed.body.users.map((user) => user.userId))];
549
+ if (userIds.length !== parsed.body.users.length) {
550
+ jsonError(res, "Each user may appear only once", 400);
551
+ return true;
552
+ }
553
+ for (const userId of userIds) {
554
+ if (!(await findUserById(userId))) {
555
+ jsonError(res, `Unknown user ${userId}`, 400);
556
+ return true;
557
+ }
558
+ }
559
+ const state = await setSessionShares(
560
+ root,
561
+ parsed.body,
562
+ actor.kind === "user" ? actor.userId : null,
563
+ );
564
+ // Committed: presence sockets re-check access, so a revoked viewer leaves.
565
+ realtimeBus.publish(sessionAccessTopic(root.id), null);
566
+ putSessionShares.respond(res, 200, state);
367
567
  return true;
368
568
  }
369
569
 
package/src/http/stats.ts CHANGED
@@ -19,6 +19,7 @@ import { isMultiRuntimeEnabled } from "../utils/multi-runtime";
19
19
  import { resolveHttpFavoriteOwner } from "./favorite-owner";
20
20
  import { route } from "./route-def";
21
21
  import { scheduleSummaryWithFavoriteSchema } from "./schedules";
22
+ import { readableByFor, resolveSessionActor } from "./session-access";
22
23
 
23
24
  // ─── Response schemas ────────────────────────────────────────────────────────
24
25
 
@@ -222,11 +223,12 @@ export async function handleStats(
222
223
  if (!parsed) return true;
223
224
  const limit = parsed.query.limit ?? 100;
224
225
  const agentId = parsed.query.agentId;
226
+ const readableBy = readableByFor(await resolveSessionActor(req, myAgentId));
225
227
  let logs: AgentLog[] = [];
226
228
  if (agentId) {
227
- logs = (await getLogsByAgentId(agentId)).slice(0, limit);
229
+ logs = (await getLogsByAgentId(agentId, readableBy)).slice(0, limit);
228
230
  } else {
229
- logs = await getAllLogs(limit);
231
+ logs = await getAllLogs(limit, readableBy);
230
232
  }
231
233
  listLogs.respond(res, 200, { logs });
232
234
  return true;
@@ -306,7 +308,9 @@ export async function handleStats(
306
308
  }
307
309
 
308
310
  if (getConcurrentContextRoute.match(req.method, pathSegments)) {
309
- const context = await getConcurrentContext();
311
+ const context = await getConcurrentContext(
312
+ readableByFor(await resolveSessionActor(req, myAgentId)),
313
+ );
310
314
  getConcurrentContextRoute.respond(res, 200, context);
311
315
  return true;
312
316
  }
package/src/http/tasks.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  } from "../be/db";
38
38
  import { modelProfileRefError, taskProfileError } from "../be/db/model-profiles";
39
39
  import { explicitModelErrorForAgent } from "../be/model-validation";
40
+ import { hasSessionAccess, type SessionAccess } from "../be/session-shares";
40
41
  import {
41
42
  getTaskSteeringFields,
42
43
  markSteeringUndeliverable,
@@ -80,7 +81,15 @@ import { getRequestAuth } from "../utils/request-auth-context";
80
81
  import { scrubSecrets } from "../utils/secret-scrubber";
81
82
  import { rejectGuest } from "./request-principal";
82
83
  import { buildTaskRequestedBy, TaskRequestedBySchema } from "./requested-by";
83
- import { route } from "./route-def";
84
+ import { bodyTask, route } from "./route-def";
85
+ import {
86
+ canReadSession,
87
+ readableByFor,
88
+ resolveSessionActor,
89
+ SESSION_READ_DENIED,
90
+ SESSION_WRITE_DENIED,
91
+ sessionAccessFor,
92
+ } from "./session-access";
84
93
  import { jsonError, parseBody } from "./utils";
85
94
 
86
95
  // ─── Response Schemas ────────────────────────────────────────────────────────
@@ -207,7 +216,7 @@ const listTasks = route({
207
216
  pattern: ["api", "tasks"],
208
217
  summary: "List tasks with filters",
209
218
  description:
210
- "Returns tasks with the full `task` text replaced by a bounded `taskPreview` and completion/integration blobs dropped by default — list views only need the preview. Pass `fields=full` to restore the full `AgentTask`, or `fields=timeline` for the narrow shape the dashboard timeline draws. Fetch a single task in full via `GET /api/tasks/{id}`. `total` (the filtered row count, ignoring limit/offset) is computed only with `includeTotal=true`.",
219
+ "Returns tasks with the full `task` text replaced by a bounded `taskPreview` and completion/integration blobs dropped by default — list views only need the preview. Pass `fields=full` to restore the full `AgentTask`, or `fields=timeline` for the narrow shape the dashboard timeline draws. Every shape gives a user caller only tasks of sessions they can read. Fetch a single task in full via `GET /api/tasks/{id}`. `total` (the filtered row count, ignoring limit/offset) is computed only with `includeTotal=true`.",
211
220
  tags: ["Tasks"],
212
221
  query: z.object({
213
222
  /** Single status, or comma-separated list (e.g. "failed,cancelled"). */
@@ -263,6 +272,7 @@ const createTask = route({
263
272
  method: "post",
264
273
  path: "/api/tasks",
265
274
  pattern: ["api", "tasks"],
275
+ session: bodyTask("parentTaskId"),
266
276
  summary: "Create a new task",
267
277
  tags: ["Tasks"],
268
278
  body: z
@@ -340,6 +350,7 @@ const promoteDraftTaskRoute = route({
340
350
  method: "post",
341
351
  path: "/api/tasks/{id}/promote-draft",
342
352
  pattern: ["api", "tasks", null, "promote-draft"],
353
+ session: { taskParam: "id" },
343
354
  summary: "Promote a draft task out of the pre-dispatch draft state",
344
355
  description:
345
356
  "Transitions a `draft` task (#1240 — created with attachments still uploading) to its normal dispatch-eligible status: `offered` if it was offered to an agent, `pending` if it has an owning agent (the common case — UI-composer tasks default to Lead), otherwise `unassigned`. Called by the UI composer once its attachment upload batch settles, whether every file uploaded, some failed, or all failed — a draft must never be stranded by an upload error. Idempotent: calling it on a task that already left `draft` returns the current task unchanged rather than erroring, so a retried request is safe.",
@@ -357,6 +368,7 @@ const updateSession = route({
357
368
  method: "put",
358
369
  path: "/api/tasks/{id}/session",
359
370
  pattern: ["api", "tasks", null, "session"],
371
+ session: { taskParam: "id" },
360
372
  summary: "Update provider session ID and harness metadata for a task",
361
373
  tags: ["Tasks"],
362
374
  params: z.object({ id: z.string() }),
@@ -392,6 +404,7 @@ const cancelTaskRoute = route({
392
404
  method: "post",
393
405
  path: "/api/tasks/{id}/cancel",
394
406
  pattern: ["api", "tasks", null, "cancel"],
407
+ session: { taskParam: "id" },
395
408
  summary: "Cancel a pending or in-progress task",
396
409
  tags: ["Tasks"],
397
410
  params: z.object({ id: z.string() }),
@@ -407,6 +420,7 @@ const steerTaskRoute = route({
407
420
  method: "post",
408
421
  path: "/api/tasks/{id}/steer",
409
422
  pattern: ["api", "tasks", null, "steer"],
423
+ session: { taskParam: "id" },
410
424
  summary: "Deliver a steering message to a running task",
411
425
  tags: ["Tasks"],
412
426
  params: z.object({ id: z.string() }),
@@ -439,6 +453,7 @@ const getTaskSteeringMessagesRoute = route({
439
453
  method: "get",
440
454
  path: "/api/tasks/{id}/steering-messages",
441
455
  pattern: ["api", "tasks", null, "steering-messages"],
456
+ session: { taskParam: "id" },
442
457
  summary: "List steering messages for a task",
443
458
  tags: ["Tasks"],
444
459
  params: z.object({ id: z.string() }),
@@ -467,6 +482,7 @@ const markSteeringDeliveredRoute = route({
467
482
  method: "post",
468
483
  path: "/api/steering-messages/{id}/delivered",
469
484
  pattern: ["api", "steering-messages", null, "delivered"],
485
+ session: { steeringParam: "id" },
470
486
  summary: "Mark a steering message delivered",
471
487
  tags: ["Tasks"],
472
488
  params: z.object({ id: z.string() }),
@@ -492,6 +508,7 @@ const markSteeringHandledRoute = route({
492
508
  method: "post",
493
509
  path: "/api/steering-messages/{id}/handled",
494
510
  pattern: ["api", "steering-messages", null, "handled"],
511
+ session: { steeringParam: "id" },
495
512
  summary: "Mark a steering message handled",
496
513
  description:
497
514
  "Optionally accepts a JSON body `{ note?: string }` — a short acceptance note describing how the steering was incorporated, persisted as `handledNote`.",
@@ -517,6 +534,7 @@ const markSteeringUndeliverableRoute = route({
517
534
  method: "post",
518
535
  path: "/api/steering-messages/{id}/undeliverable",
519
536
  pattern: ["api", "steering-messages", null, "undeliverable"],
537
+ session: { steeringParam: "id" },
520
538
  summary: "Promote an undeliverable steering message",
521
539
  tags: ["Tasks"],
522
540
  params: z.object({ id: z.string() }),
@@ -542,6 +560,7 @@ const getTask = route({
542
560
  method: "get",
543
561
  path: "/api/tasks/{id}",
544
562
  pattern: ["api", "tasks", null],
563
+ session: { taskParam: "id" },
545
564
  summary: "Get task details with logs and attachments",
546
565
  description:
547
566
  "Returns the full `AgentTask` row decorated with `logs` (capped by `logsLimit`) and `attachments` (pointer-based artifacts stored on the task, ordered by `created_at`).",
@@ -553,6 +572,7 @@ const getTask = route({
553
572
  }),
554
573
  responses: {
555
574
  200: { description: "Task with logs and attachments", schema: GetTaskResponseSchema },
575
+ 403: { description: "A user caller cannot read the task's session" },
556
576
  404: { description: "Task not found" },
557
577
  },
558
578
  });
@@ -561,6 +581,7 @@ const updateTaskProgressRoute = route({
561
581
  method: "post",
562
582
  path: "/api/tasks/{id}/progress",
563
583
  pattern: ["api", "tasks", null, "progress"],
584
+ session: { taskParam: "id" },
564
585
  summary: "Update task progress text",
565
586
  tags: ["Tasks"],
566
587
  params: z.object({ id: z.string() }),
@@ -579,6 +600,7 @@ const finishTask = route({
579
600
  method: "post",
580
601
  path: "/api/tasks/{id}/finish",
581
602
  pattern: ["api", "tasks", null, "finish"],
603
+ session: { taskParam: "id" },
582
604
  summary: "Mark task as completed or failed (runner endpoint)",
583
605
  tags: ["Tasks"],
584
606
  params: z.object({ id: z.string() }),
@@ -631,6 +653,7 @@ const pauseTaskRoute = route({
631
653
  method: "post",
632
654
  path: "/api/tasks/{id}/pause",
633
655
  pattern: ["api", "tasks", null, "pause"],
656
+ session: { taskParam: "id" },
634
657
  summary: "Pause an in-progress task",
635
658
  tags: ["Tasks"],
636
659
  params: z.object({ id: z.string() }),
@@ -646,6 +669,7 @@ const resumeTaskRoute = route({
646
669
  method: "post",
647
670
  path: "/api/tasks/{id}/resume",
648
671
  pattern: ["api", "tasks", null, "resume"],
672
+ session: { taskParam: "id" },
649
673
  summary: "Resume a paused task",
650
674
  tags: ["Tasks"],
651
675
  params: z.object({ id: z.string() }),
@@ -661,6 +685,7 @@ const supersedeTaskRoute = route({
661
685
  method: "post",
662
686
  path: "/api/tasks/{id}/supersede",
663
687
  pattern: ["api", "tasks", null, "supersede"],
688
+ session: { taskParam: "id" },
664
689
  summary: "Supersede an in-progress task (terminate + spawn resume follow-up)",
665
690
  description:
666
691
  'Marks the original task `superseded` (terminal) and creates a fresh `taskType="resume"` follow-up so a worker can pick up the work in a new provider session. Workflow-step tasks (those with `workflowRunStepId`) are carved out: the original is marked `failed` with reason `superseded_workflow_task` and no follow-up is created — the workflow engine\'s retry/failure policy applies.',
@@ -683,6 +708,7 @@ const updateTaskVcsRoute = route({
683
708
  method: "patch",
684
709
  path: "/api/tasks/{id}/vcs",
685
710
  pattern: ["api", "tasks", null, "vcs"],
711
+ session: { taskParam: "id" },
686
712
  summary: "Update VCS (PR/MR) info for a task",
687
713
  tags: ["Tasks"],
688
714
  params: z.object({ id: z.string() }),
@@ -704,6 +730,7 @@ const updateTaskTitleRoute = route({
704
730
  method: "patch",
705
731
  path: "/api/tasks/{id}/title",
706
732
  pattern: ["api", "tasks", null, "title"],
733
+ session: { taskParam: "id" },
707
734
  summary: "Set or clear a task's display title (session rename)",
708
735
  description:
709
736
  "Sets a human-facing display title override on a task. The sessions UI only reads this from root tasks (session list items), but titles on child tasks are harmless. Pass `title: null` (or an empty string) to clear the override and fall back to the task prompt.",
@@ -722,22 +749,37 @@ const updateTaskTitleRoute = route({
722
749
  },
723
750
  });
724
751
 
752
+ /** True unless the request acts for a user who lacks write access to the task's session. */
753
+ async function hasSessionWrite(
754
+ req: IncomingMessage,
755
+ myAgentId: string | undefined,
756
+ taskId: string,
757
+ ): Promise<boolean> {
758
+ const access = await sessionAccessFor(await resolveSessionActor(req, myAgentId), taskId);
759
+ return hasSessionAccess(access, "write");
760
+ }
761
+
725
762
  async function canSteerTask(
726
763
  req: IncomingMessage,
727
764
  myAgentId: string | undefined,
728
765
  task: AgentTask,
766
+ sessionAccess: SessionAccess = "none",
729
767
  ): Promise<boolean> {
768
+ const auth = getRequestAuth(req);
730
769
  const resource: RbacResource = {
731
770
  kind: "task",
732
771
  taskId: task.id,
733
- requestedByUserId: task.requestedByUserId,
772
+ // A user with a write share steers as if the session were their own; the
773
+ // verb gate (and any role grant) still applies. Only an actual share
774
+ // counts: `owner` from an ownerless root must not widen steering.
775
+ requestedByUserId:
776
+ auth?.kind === "user" && sessionAccess === "write" ? auth.userId : task.requestedByUserId,
734
777
  creatorAgentId: task.creatorAgentId,
735
778
  agentId: task.agentId,
736
779
  };
737
780
  let principal: RbacPrincipal;
738
781
  let verb: "task.steer.any" | "task.steer.own";
739
782
 
740
- const auth = getRequestAuth(req);
741
783
  if (auth?.kind === "operator") {
742
784
  principal = { kind: "operator" };
743
785
  verb = "task.steer.own";
@@ -906,6 +948,9 @@ export async function handleTasks(
906
948
  if (validated.length > 0) source = validated;
907
949
  }
908
950
 
951
+ // Every shape carries task text, so a user sees only sessions they can
952
+ // read, and `total` counts the same rows.
953
+ const actor = await resolveSessionActor(req, myAgentId);
909
954
  const filters = {
910
955
  status,
911
956
  agentId: parsed.query.agentId || undefined,
@@ -925,6 +970,7 @@ export async function handleTasks(
925
970
  orderBy: parsed.query.orderBy,
926
971
  limit: parsed.query.limit,
927
972
  offset: parsed.query.offset,
973
+ readableBy: actor.kind === "user" ? { userId: actor.userId } : undefined,
928
974
  };
929
975
  // List responses default to slim (full `task` text → bounded `taskPreview`,
930
976
  // heavy blobs dropped); `?fields=full` restores the full `AgentTask`.
@@ -1013,6 +1059,11 @@ export async function handleTasks(
1013
1059
  jsonError(res, `You don't have context for parent task ${parentTask.id}.`, 403);
1014
1060
  return true;
1015
1061
  }
1062
+ // A follow-up in someone else's session needs a write share.
1063
+ if (parentTask && !(await hasSessionWrite(req, myAgentId, parentTask.id))) {
1064
+ jsonError(res, SESSION_WRITE_DENIED, 403);
1065
+ return true;
1066
+ }
1016
1067
  // A chat-sdk contextKey binds the task to a chat thread that its assignee and creator
1017
1068
  // may then read and post to. An agent may only name a thread other than the parent's
1018
1069
  // with the lead's chat post permission.
@@ -1285,7 +1336,13 @@ export async function handleTasks(
1285
1336
  jsonError(res, "Task not found", 404);
1286
1337
  return true;
1287
1338
  }
1288
- if (!(await canSteerTask(req, myAgentId, task))) {
1339
+ const sessionActor = await resolveSessionActor(req, myAgentId);
1340
+ const steerAccess = await sessionAccessFor(sessionActor, task.id);
1341
+ if (!hasSessionAccess(steerAccess, "write")) {
1342
+ jsonError(res, SESSION_WRITE_DENIED, 403);
1343
+ return true;
1344
+ }
1345
+ if (!(await canSteerTask(req, myAgentId, task, steerAccess))) {
1289
1346
  jsonError(res, "Forbidden: caller cannot steer this task", 403);
1290
1347
  return true;
1291
1348
  }
@@ -1338,6 +1395,10 @@ export async function handleTasks(
1338
1395
  jsonError(res, "Task not found", 404);
1339
1396
  return true;
1340
1397
  }
1398
+ if (!(await canReadSession(req, myAgentId, parsed.params.id))) {
1399
+ jsonError(res, SESSION_READ_DENIED, 403);
1400
+ return true;
1401
+ }
1341
1402
  getTaskSteeringMessagesRoute.respond(res, 200, {
1342
1403
  messages: await getSteeringMessagesForTask(parsed.params.id),
1343
1404
  });
@@ -1357,7 +1418,12 @@ export async function handleTasks(
1357
1418
  return true;
1358
1419
  }
1359
1420
 
1421
+ // A user token naming an agent in X-Agent-ID still reads only its sessions.
1360
1422
  if (parsed.query.taskId) {
1423
+ if (!(await canReadSession(req, myAgentId, parsed.query.taskId))) {
1424
+ jsonError(res, SESSION_READ_DENIED, 403);
1425
+ return true;
1426
+ }
1361
1427
  const task = await getTaskById(parsed.query.taskId);
1362
1428
  const messages =
1363
1429
  task?.agentId === myAgentId ? await getPendingSteeringForTask(parsed.query.taskId) : [];
@@ -1365,8 +1431,9 @@ export async function handleTasks(
1365
1431
  return true;
1366
1432
  }
1367
1433
 
1434
+ const actor = await resolveSessionActor(req, myAgentId);
1368
1435
  getPendingSteeringMessagesRoute.respond(res, 200, {
1369
- messages: await getPendingSteeringForAgent(myAgentId),
1436
+ messages: await getPendingSteeringForAgent(myAgentId, readableByFor(actor)),
1370
1437
  });
1371
1438
  return true;
1372
1439
  }
@@ -1529,6 +1596,11 @@ export async function handleTasks(
1529
1596
  jsonError(res, "Task not found", 404);
1530
1597
  return true;
1531
1598
  }
1599
+ const readAccess = await sessionAccessFor(await resolveSessionActor(req, myAgentId), task.id);
1600
+ if (!hasSessionAccess(readAccess, "read")) {
1601
+ jsonError(res, "You don't have access to this session. Ask the owner to share it.", 403);
1602
+ return true;
1603
+ }
1532
1604
 
1533
1605
  const logs = await getLogsByTaskId(parsed.params.id, parsed.query.logsLimit ?? 200);
1534
1606
  const attachments = await getTaskAttachments(parsed.params.id);
@@ -1758,7 +1830,11 @@ export async function handleTasks(
1758
1830
  jsonError(res, "Missing X-Agent-ID header", 400);
1759
1831
  return true;
1760
1832
  }
1761
- const pausedTasks = await getPausedTasksForAgent(myAgentId);
1833
+ // A user token naming an agent in X-Agent-ID still reads only its sessions.
1834
+ const pausedTasks = await getPausedTasksForAgent(
1835
+ myAgentId,
1836
+ readableByFor(await resolveSessionActor(req, myAgentId)),
1837
+ );
1762
1838
  const tasks = await Promise.all(
1763
1839
  pausedTasks.map(async (task) => {
1764
1840
  const requestedBy = await buildTaskRequestedBy(task);