@desplega.ai/agent-swarm 2.4.3 → 2.4.4

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 (150) hide show
  1. package/dist/{acp-adapter-1885dpr7.js → acp-adapter-thq04adh.js} +1 -2
  2. package/dist/{actions-pn76n32q.js → actions-j6ywh6x5.js} +17 -17
  3. package/dist/{amp-adapter-af84rx1d.js → amp-adapter-k8b5xmj8.js} +2 -2
  4. package/dist/{app-6sv8mhzm.js → app-rb7vc02s.js} +3 -3
  5. package/dist/{assistant-sc2tf825.js → assistant-ftta1zjf.js} +18 -18
  6. package/dist/{boot-reembed-y3m0ydzj.js → boot-reembed-srp6v6af.js} +1 -1
  7. package/dist/{boot-reembed-gvxjs2py.js → boot-reembed-xns7h5h9.js} +1 -1
  8. package/dist/{boot-scrub-sweep-1j9mk05f.js → boot-scrub-sweep-3qqrh2mz.js} +2 -2
  9. package/dist/{boot-seeds-37sp626b.js → boot-seeds-9ks8hce7.js} +4 -4
  10. package/dist/{claude-adapter-rp0jtnwr.js → claude-adapter-k5ksyres.js} +2 -2
  11. package/dist/{claude-managed-setup-rdt248k7.js → claude-managed-setup-n5a5eqks.js} +1 -1
  12. package/dist/{claude-sdk-session-53rcw5we.js → claude-sdk-session-2j3j4kwn.js} +2 -2
  13. package/dist/{cli-v87dr6e3.js → cli-1d70crsy.js} +1 -1
  14. package/dist/{cli-1ma335fz.js → cli-1ephgwnq.js} +2 -2
  15. package/dist/{cli-4yef8ghv.js → cli-2v045xyf.js} +3 -3
  16. package/dist/{cli-73ezfhha.js → cli-2y91wnbh.js} +10 -5
  17. package/dist/{cli-1eszr343.js → cli-2zqpkvtt.js} +1 -1
  18. package/dist/{cli-e4d78xsv.js → cli-37yb7307.js} +1 -1
  19. package/dist/{cli-8y99kfey.js → cli-3cgngyd8.js} +9 -9
  20. package/dist/{cli-wk28hzqn.js → cli-44159k28.js} +1 -1
  21. package/dist/{cli-qp2m4btv.js → cli-4dg55wsk.js} +2 -2
  22. package/dist/{cli-0dxxwfkt.js → cli-51y09c6p.js} +17 -14
  23. package/dist/{cli-aa3tb29r.js → cli-55qmbp6z.js} +1 -1
  24. package/dist/{cli-mzkwkbdy.js → cli-82d3nkc3.js} +1 -1
  25. package/dist/{cli-sds5zzs8.js → cli-9fhkkpj5.js} +78 -12
  26. package/dist/{cli-vq9gqarq.js → cli-amyhm3gj.js} +1 -1
  27. package/dist/{cli-bxrqvpg5.js → cli-b8k7epj1.js} +5 -5
  28. package/dist/{cli-8vverzf7.js → cli-ba8ne1es.js} +5 -0
  29. package/dist/{cli-58vwn100.js → cli-bnz0r3kc.js} +1 -1
  30. package/dist/{cli-6eyncg0y.js → cli-cynvc9fj.js} +34 -6
  31. package/dist/{cli-f0p0f6ja.js → cli-dqb4n629.js} +7 -7
  32. package/dist/{cli-pygdsgh8.js → cli-esxerzg7.js} +1 -1
  33. package/dist/{cli-dm8p2dpf.js → cli-gqvh8d8a.js} +6 -6
  34. package/dist/{cli-p0qerd2r.js → cli-hj3debgw.js} +1 -1
  35. package/dist/{cli-836ehxmt.js → cli-nc35pxb7.js} +2 -2
  36. package/dist/{cli-vcgscaan.js → cli-p20r8w42.js} +8 -6
  37. package/dist/{cli-1xsraagq.js → cli-pccfmtb5.js} +4 -4
  38. package/dist/{cli-s9rr4gr2.js → cli-q6r7t299.js} +1036 -298
  39. package/dist/{cli-tkf4x6ha.js → cli-qkhc5aqy.js} +11 -11
  40. package/dist/{cli-1n9gdg56.js → cli-t7cyef5g.js} +4 -4
  41. package/dist/{cli-038fga5k.js → cli-te6b5wvg.js} +148 -183
  42. package/dist/{cli-kgnkyabk.js → cli-v3yr2gbg.js} +109 -35
  43. package/dist/{cli-h0mgxann.js → cli-vdm20wej.js} +2 -2
  44. package/dist/{cli-zvbb4n0e.js → cli-vq97g1jq.js} +2 -2
  45. package/dist/{cli-gx0j2khs.js → cli-w6nadkh4.js} +3 -3
  46. package/dist/{cli-99jez72r.js → cli-w9etnnxk.js} +6 -1
  47. package/dist/{cli-npfe9t1s.js → cli-xc27fwzg.js} +2 -2
  48. package/dist/{cli-6aen3ezb.js → cli-xj0re5tm.js} +1 -1
  49. package/dist/{cli-aypbg9r2.js → cli-zrk6jxv8.js} +1 -1
  50. package/dist/{cli-1012xb7j.js → cli-zrwpctp4.js} +20 -20
  51. package/dist/cli.js +41 -16
  52. package/dist/{codex-adapter-2zrmab9b.js → codex-adapter-968xwfy7.js} +1 -1
  53. package/dist/{codex-session-runner-kbkz62vc.js → codex-session-runner-ht4z99fa.js} +1 -1
  54. package/dist/{commands-hd574th6.js → commands-pcymaeg9.js} +2 -2
  55. package/dist/{controller-pz5mcfg4.js → controller-fpjyk46r.js} +2 -2
  56. package/dist/{controllers-connect-5rgq2hfk.js → controllers-connect-mwzx1fh6.js} +18 -18
  57. package/dist/{cursor-adapter-mgaprq0j.js → cursor-adapter-cp4s2e2f.js} +1 -1
  58. package/dist/{db-h06dxp05.js → db-sgd12bt9.js} +3 -3
  59. package/dist/{db-6nf1207q.js → db-xsjg9jqq.js} +1 -1
  60. package/dist/{dispatcher-zthpy4m1.js → dispatcher-x0jfw2c9.js} +16 -16
  61. package/dist/{dsh-adapter-64dhw0j7.js → dsh-adapter-jrq7ma7h.js} +1 -1
  62. package/dist/{external-channel-x01t7ke6.js → external-channel-8njj4h3b.js} +2 -2
  63. package/dist/{grok-adapter-h21jv594.js → grok-adapter-kkr97v2f.js} +2 -3
  64. package/dist/handlers-0pvee9nw.js +39 -0
  65. package/dist/{heartbeat-zzfksckz.js → heartbeat-wd03zhn0.js} +19 -19
  66. package/dist/{hook-z4zhzj1f.js → hook-f0vghj42.js} +1 -1
  67. package/dist/{hook-3xfqhh2s.js → hook-xzj2ts2p.js} +1 -1
  68. package/dist/{http-q813tmya.js → http-4hwxv62p.js} +255 -117
  69. package/dist/{index-va3y63es.js → index-6srwvd5b.js} +1 -2
  70. package/dist/{index-220tk9bw.js → index-97q4xn03.js} +19 -10
  71. package/dist/{index-s98037v0.js → index-cbxkmfc5.js} +1 -2
  72. package/dist/{index-m0a4ydqj.js → index-gdh8jrdm.js} +16 -16
  73. package/dist/{index-wbbs14c5.js → index-hg0x2cdv.js} +16 -16
  74. package/dist/{index-d0g18bax.js → index-thm0y6a8.js} +19 -19
  75. package/dist/{index-x8xbyn1t.js → index-ww2vznkp.js} +1 -1
  76. package/dist/{keepalive-y5t1my7m.js → keepalive-hrh7xnr9.js} +3 -3
  77. package/dist/{lead-2xwtq66b.js → lead-ee8vazz2.js} +26 -27
  78. package/dist/{maintenance-ybfsaedv.js → maintenance-e8wnwnqx.js} +1 -1
  79. package/dist/{oauth-refresh-sweep-909jmn12.js → oauth-refresh-sweep-zm0yj4sj.js} +2 -2
  80. package/dist/{onboard-xn6fkkys.js → onboard-dtwcq603.js} +2 -2
  81. package/dist/{otel-impl-e6mn0z13.js → otel-impl-w3fbhpn0.js} +1 -1
  82. package/dist/{pricing-refresh-ygd362ep.js → pricing-refresh-j38dehfs.js} +3 -3
  83. package/dist/{rbac-roles-c9axvfnz.js → rbac-roles-ae8yttq4.js} +2 -2
  84. package/dist/{rbac-roles-mb470p4e.js → rbac-roles-ejxx9b35.js} +2 -2
  85. package/dist/{render-v2-g0306nh1.js → render-v2-n16n0phe.js} +3 -3
  86. package/dist/{resume-v315hnq6.js → resume-z0fgjc38.js} +5 -5
  87. package/dist/{session-access-2zg285a1.js → session-access-cv3mq6mc.js} +6 -4
  88. package/dist/{setup-a4mtf02k.js → setup-mafhj3yt.js} +1 -1
  89. package/dist/{work-objects-9fr0fgtn.js → work-objects-8b86mgjp.js} +19 -19
  90. package/dist/{worker-qae22jeb.js → worker-16s75hm5.js} +26 -27
  91. package/dist/worker-token-xf11yyds.js +136 -0
  92. package/openapi.json +295 -12
  93. package/package.json +4 -4
  94. package/src/apps/action-runner.ts +4 -0
  95. package/src/apps/catalog.generated.json +3 -0
  96. package/src/apps/page-validator.ts +8 -2
  97. package/src/be/migrations/232_agent_session_tokens.sql +41 -0
  98. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +10 -1
  99. package/src/be/swarm-config-guard.ts +16 -0
  100. package/src/be/users.ts +169 -12
  101. package/src/cli.tsx +24 -0
  102. package/src/commands/worker-token.ts +164 -0
  103. package/src/http/apps.ts +43 -5
  104. package/src/http/codex-oauth-keep-warm.ts +27 -9
  105. package/src/http/config.ts +5 -3
  106. package/src/http/core.ts +2 -0
  107. package/src/http/session-access.ts +51 -2
  108. package/src/http/sessions.ts +249 -10
  109. package/src/http/tasks.ts +4 -32
  110. package/src/providers/acp-adapter.ts +33 -13
  111. package/src/providers/acp-swarm-events.ts +1 -1
  112. package/src/providers/amp-adapter.ts +1 -1
  113. package/src/providers/codex-oauth/storage.ts +1 -1
  114. package/src/rbac/legacy-policy.ts +3 -1
  115. package/src/rbac/permissions.ts +4 -0
  116. package/src/tests/acp-adapter.test.ts +10 -23
  117. package/src/tests/acp-fake-agent.ts +36 -0
  118. package/src/tests/acp-swarm-events.test.ts +1 -1
  119. package/src/tests/agent-session-tokens.test.ts +452 -0
  120. package/src/tests/apps-action-parent-task.test.ts +295 -0
  121. package/src/tests/codex-oauth-keep-warm.test.ts +85 -1
  122. package/src/tests/config-session-auth.test.ts +14 -0
  123. package/src/tests/docs-index-footprint.test.js +2 -2
  124. package/src/tests/grok-adapter.test.ts +3 -3
  125. package/src/tests/heartbeat-jev-gate.test.ts +23 -0
  126. package/src/tests/migration-232-agent-session-tokens.test.ts +67 -0
  127. package/src/tests/rbac-engine.test.ts +1 -0
  128. package/src/tests/runtime-child-token-cleanup.test.ts +3 -3
  129. package/src/tests/script-connections.test.ts +2 -0
  130. package/src/tests/session-route-posture.test.ts +2 -0
  131. package/src/tests/worker-token-cli.test.ts +76 -0
  132. package/src/tests/workflow-executors.test.ts +4 -2
  133. package/src/tests/workflow-system-one-extraction.test.ts +805 -0
  134. package/src/types.ts +2 -1
  135. package/src/utils/request-auth-context.ts +2 -1
  136. package/src/workflows/definition.ts +7 -1
  137. package/src/workflows/engine.ts +18 -12
  138. package/src/workflows/executors/index.ts +1 -0
  139. package/src/workflows/executors/registry.ts +2 -0
  140. package/src/workflows/executors/system-one-decision.ts +56 -255
  141. package/src/workflows/executors/system-one-extract-wire.ts +57 -0
  142. package/src/workflows/executors/system-one-extraction.ts +830 -0
  143. package/src/workflows/executors/system-one-providers.ts +277 -110
  144. package/src/workflows/executors/system-one-transport.ts +264 -0
  145. package/templates/official/lead/HEARTBEAT.md +1 -1
  146. package/templates/skills/apps/SKILL.md +3 -1
  147. package/templates/skills/apps/content.md +3 -1
  148. package/templates/skills/heartbeat-runbook/content.md +3 -3
  149. package/dist/cli-v1sjhn52.js +0 -7
  150. package/dist/handlers-2qjw434w.js +0 -39
package/src/http/core.ts CHANGED
@@ -547,6 +547,8 @@ export async function handleCore(
547
547
  return true;
548
548
  }
549
549
  req.headers["x-runtime-instance-id"] = auth.runtimeId;
550
+ }
551
+ if (auth.tokenKind !== "task") {
550
552
  const sourceHeader = req.headers["x-source-task-id"];
551
553
  const taskId = Array.isArray(sourceHeader) ? sourceHeader[0] : sourceHeader;
552
554
  if (taskId !== undefined && (await getTaskById(taskId))?.agentId !== auth.agentId) {
@@ -1,4 +1,4 @@
1
- import type { IncomingMessage } from "node:http";
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { getSteeringMessageById } from "../be/db";
3
3
  import { getActiveSessionById } from "../be/db/active-sessions";
4
4
  import { getUserGrant } from "../be/rbac-roles";
@@ -10,10 +10,12 @@ import {
10
10
  sessionAccessForRoot,
11
11
  } from "../be/session-shares";
12
12
  import { findUserById } from "../be/users";
13
- import { isRbacEnabled } from "../rbac";
13
+ import { can, isRbacEnabled, type RbacPrincipal } from "../rbac";
14
+ import type { AgentTask } from "../types";
14
15
  import { getRequestAuth } from "../utils/request-auth-context";
15
16
  import { dashboardUserIdHeader } from "./favorite-owner";
16
17
  import { type RouteDef, routePathParams } from "./route-def";
18
+ import { jsonError } from "./utils";
17
19
 
18
20
  /**
19
21
  * Who a request acts for when it touches a session.
@@ -76,6 +78,53 @@ export function readableByFor(actor: SessionActor): { userId: string | null } |
76
78
  export const SESSION_WRITE_DENIED =
77
79
  "You can't write in this session. Ask the owner to share it with edit access.";
78
80
 
81
+ /** True unless the request acts for a user who lacks write access to the task's session. */
82
+ export async function hasSessionWrite(
83
+ req: IncomingMessage,
84
+ myAgentId: string | undefined,
85
+ taskId: string,
86
+ ): Promise<boolean> {
87
+ const access = await sessionAccessFor(await resolveSessionActor(req, myAgentId), taskId);
88
+ return hasSessionAccess(access, "write");
89
+ }
90
+
91
+ /**
92
+ * The parent gate shared by `POST /api/tasks` and `POST /api/apps/{id}/actions/{name}`.
93
+ * The child inherits the parent's thread context, so an agent caller must own or
94
+ * have created the parent (`task.parent.attach`; operator and user callers keep
95
+ * their access), and a follow-up in someone else's session needs a write share.
96
+ * Writes the 403 and returns true when denied.
97
+ */
98
+ export async function denyParentAttach(
99
+ req: IncomingMessage,
100
+ res: ServerResponse,
101
+ myAgentId: string | undefined,
102
+ parentTask: Pick<AgentTask, "id" | "requestedByUserId" | "creatorAgentId" | "agentId">,
103
+ principal: RbacPrincipal,
104
+ ): Promise<boolean> {
105
+ const attach = can({
106
+ principal,
107
+ verb: "task.parent.attach",
108
+ resource: {
109
+ kind: "task",
110
+ taskId: parentTask.id,
111
+ requestedByUserId: parentTask.requestedByUserId,
112
+ creatorAgentId: parentTask.creatorAgentId,
113
+ agentId: parentTask.agentId,
114
+ },
115
+ source: "http",
116
+ });
117
+ if (!attach.allow) {
118
+ jsonError(res, `You don't have context for parent task ${parentTask.id}.`, 403);
119
+ return true;
120
+ }
121
+ if (!(await hasSessionWrite(req, myAgentId, parentTask.id))) {
122
+ jsonError(res, SESSION_WRITE_DENIED, 403);
123
+ return true;
124
+ }
125
+ return false;
126
+ }
127
+
79
128
  /**
80
129
  * The denial for an actor lacking `needed` access to the session holding
81
130
  * `taskId`, or null. An unknown task passes, so the handler still answers 404.
@@ -2,11 +2,14 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import {
4
4
  countSessions,
5
+ createAgent,
6
+ getAgentById,
5
7
  getDbClient,
6
8
  getRootTaskChain,
7
9
  getTaskById,
8
10
  listRecentSessions,
9
11
  } from "../be/db";
12
+ import { getUserGrant } from "../be/rbac-roles";
10
13
  import {
11
14
  getSessionRoot,
12
15
  listActiveSessionShares,
@@ -18,13 +21,17 @@ import {
18
21
  import { getTaskSteeringFieldsForTasks } from "../be/steering";
19
22
  import { getTaskCitationsForTasks, TaskCitationSchema } from "../be/task-citations";
20
23
  import {
24
+ AgentTokenExistsError,
21
25
  findUserById,
22
26
  InvalidRuntimeTokenError,
27
+ mintAgentToken,
23
28
  mintRuntimeToken,
24
29
  mintSessionToken,
30
+ revokeAgentTokens,
25
31
  revokeSessionToken,
32
+ rotateAgentToken,
26
33
  } from "../be/users";
27
- import { can } from "../rbac";
34
+ import { can, isRbacEnabled } from "../rbac";
28
35
  import { sessionAccessTopic } from "../realtime/auth";
29
36
  import { realtimeBus } from "../realtime/bus";
30
37
  import {
@@ -39,6 +46,7 @@ import {
39
46
  } from "../types";
40
47
  import { getRequestAuth } from "../utils/request-auth-context";
41
48
  import { isRuntimePoolsEnabled } from "../utils/runtime-pools-flag";
49
+ import { agentFirstPrincipal } from "./request-principal";
42
50
  import { route } from "./route-def";
43
51
  import { resolveSessionActor, type SessionActor } from "./session-access";
44
52
  import { jsonError } from "./utils";
@@ -118,7 +126,7 @@ const mintSessionTokenRoute = route({
118
126
  },
119
127
  summary: "Mint an ephemeral task or runtime token",
120
128
  description:
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.",
129
+ "Operators can mint task or runtime tokens. Runtime and agent tokens can mint task tokens for their own assigned tasks. A child token never outlives its parent and is revoked with it.",
122
130
  tags: ["Sessions"],
123
131
  body: z
124
132
  .object({
@@ -147,7 +155,8 @@ const mintSessionTokenRoute = route({
147
155
  },
148
156
  auth: { apiKey: true },
149
157
  rbac: {
150
- ungated: "operator or runtime token minting for its own assigned task, enforced in the handler",
158
+ ungated:
159
+ "operator, or a runtime or agent token minting for its own assigned task, enforced in the handler",
151
160
  },
152
161
  });
153
162
 
@@ -157,7 +166,7 @@ const revokeSessionTokenRoute = route({
157
166
  pattern: ["api", "sessions", "tokens", null],
158
167
  summary: "Revoke an ephemeral session token",
159
168
  description:
160
- "Operators can revoke any ephemeral token. With runtime pools enabled, runtime tokens can revoke their own child task tokens. Repeated revocation has no effect.",
169
+ "Operators can revoke any ephemeral token. Agent tokens, and runtime tokens when runtime pools are enabled, can revoke their own child task tokens. Repeated revocation has no effect.",
161
170
  tags: ["Sessions"],
162
171
  params: z.object({ tokenId: z.string() }),
163
172
  responses: {
@@ -167,10 +176,234 @@ const revokeSessionTokenRoute = route({
167
176
  },
168
177
  auth: { apiKey: true },
169
178
  rbac: {
170
- ungated: "operator or runtime token revoking its own child, enforced in the handler",
179
+ ungated:
180
+ "operator, or a runtime or agent token revoking its own child, enforced in the handler",
171
181
  },
172
182
  });
173
183
 
184
+ /** Default rotation grace: the old worker token keeps working this long. */
185
+ const AGENT_TOKEN_ROTATE_GRACE_SEC = 300;
186
+
187
+ const AgentTokenSchema = z.object({
188
+ agentId: z.string(),
189
+ tokenId: z.string(),
190
+ plaintext: z.string(),
191
+ expiresAt: z.string().nullable().describe("null when the token does not expire"),
192
+ });
193
+
194
+ const agentTokenTtl = z
195
+ .number()
196
+ .int()
197
+ .positive()
198
+ .optional()
199
+ .describe("Token lifetime in ms. Omit for the default (no expiry).");
200
+
201
+ const mintAgentTokenRoute = route({
202
+ method: "post",
203
+ path: "/api/agents/{id}/token",
204
+ pattern: ["api", "agents", null, "token"],
205
+ operationId: "agents_mintWorkerToken",
206
+ summary: "Mint an agent-bound worker token",
207
+ description:
208
+ "Returns the token once. A worker runs on it in place of the swarm API key and acts only as this agent. Creates the agent when it does not exist and `name` is given. Fails with 409 when the agent already holds an active token.",
209
+ tags: ["Sessions"],
210
+ params: z.object({ id: z.string().min(1) }),
211
+ body: z
212
+ .object({ name: z.string().min(1).optional(), ttlMs: agentTokenTtl })
213
+ .strict()
214
+ .optional(),
215
+ responses: {
216
+ 200: {
217
+ description: "Minted worker token",
218
+ schema: AgentTokenSchema.extend({ agentCreated: z.boolean() }),
219
+ },
220
+ 403: { description: "Operator or user credentials required" },
221
+ 404: { description: "Agent not found and no name given" },
222
+ 409: { description: "Agent already holds an active token" },
223
+ },
224
+ rbac: { permission: "agent.token.manage" },
225
+ });
226
+
227
+ const rotateAgentTokenRoute = route({
228
+ method: "post",
229
+ path: "/api/agents/{id}/token/rotate",
230
+ pattern: ["api", "agents", null, "token", "rotate"],
231
+ operationId: "agents_rotateWorkerToken",
232
+ summary: "Rotate an agent-bound worker token",
233
+ description:
234
+ "Returns a new token once. The previous token keeps working for `graceSec` (default 300), then expires. Use revoke for a token that must stop now.",
235
+ tags: ["Sessions"],
236
+ params: z.object({ id: z.string().min(1) }),
237
+ body: z
238
+ .object({ graceSec: z.number().int().min(0).max(86_400).optional(), ttlMs: agentTokenTtl })
239
+ .strict()
240
+ .optional(),
241
+ responses: {
242
+ 200: {
243
+ description: "New worker token",
244
+ schema: AgentTokenSchema.extend({ previousExpiresAt: z.string() }),
245
+ },
246
+ 403: { description: "Operator or user credentials required" },
247
+ 404: { description: "Agent holds no active token" },
248
+ },
249
+ rbac: { permission: "agent.token.manage" },
250
+ });
251
+
252
+ const revokeAgentTokenRoute = route({
253
+ method: "delete",
254
+ path: "/api/agents/{id}/token",
255
+ pattern: ["api", "agents", null, "token"],
256
+ operationId: "agents_revokeWorkerToken",
257
+ summary: "Revoke an agent-bound worker token",
258
+ description:
259
+ "Revokes every active worker token of the agent and the task tokens they minted. Open realtime connections on them close.",
260
+ tags: ["Sessions"],
261
+ params: z.object({ id: z.string().min(1) }),
262
+ responses: {
263
+ 200: {
264
+ description: "Number of worker tokens revoked (0 when none was active)",
265
+ schema: z.object({ revoked: z.number().int() }),
266
+ },
267
+ 403: { description: "Operator or user credentials required" },
268
+ },
269
+ rbac: { permission: "agent.token.manage" },
270
+ });
271
+
272
+ const AGENT_TOKEN_ROUTES = [mintAgentTokenRoute, rotateAgentTokenRoute, revokeAgentTokenRoute];
273
+
274
+ /**
275
+ * Gate the worker-token routes. Only an operator key or a user may manage
276
+ * them, never a session token. The principal is agent-first: an operator key
277
+ * sent with X-Agent-ID acts as that agent and is refused, as on the
278
+ * controller-token route.
279
+ */
280
+ async function ensureAgentTokenManager(
281
+ req: IncomingMessage,
282
+ res: ServerResponse,
283
+ agentId: string,
284
+ ): Promise<boolean> {
285
+ const auth = getRequestAuth(req);
286
+ if (auth?.kind !== "operator" && auth?.kind !== "user") {
287
+ jsonError(res, "Worker tokens require operator or user credentials", 403);
288
+ return false;
289
+ }
290
+ const header = req.headers["x-agent-id"];
291
+ const decision = can({
292
+ principal: await agentFirstPrincipal(req, Array.isArray(header) ? header[0] : header),
293
+ verb: "agent.token.manage",
294
+ resource: { kind: "agent", agentId },
295
+ source: "http",
296
+ });
297
+ if (!decision.allow) {
298
+ jsonError(res, decision.reason, 403);
299
+ return false;
300
+ }
301
+ // A worker token carries the target agent's privileges and reads that
302
+ // agent's resolved config, global secrets included. A user without a
303
+ // grant-all role must already hold both, and may not manage the token of an
304
+ // agent that may itself act on any agent (a lead).
305
+ if (auth.kind === "user" && isRbacEnabled()) {
306
+ const grant = await getUserGrant(auth.userId);
307
+ if (!grant.grantsAll) {
308
+ const target = await getAgentById(agentId);
309
+ const targetActsOnAny = can({
310
+ principal: { kind: "agent", agentId, isLead: target?.isLead ?? false },
311
+ verb: "agent.act.any",
312
+ resource: { kind: "agent", agentId },
313
+ source: "http",
314
+ }).allow;
315
+ if (targetActsOnAny) {
316
+ jsonError(res, "A lead agent's worker token requires a grant-all role", 403);
317
+ return false;
318
+ }
319
+ const missing = (["agent.act.any", "config.read.secrets"] as const).filter(
320
+ (verb) => !grant.verbs.has(verb),
321
+ );
322
+ if (missing.length > 0) {
323
+ jsonError(res, `Worker tokens also require: ${missing.join(", ")}`, 403);
324
+ return false;
325
+ }
326
+ }
327
+ }
328
+ return true;
329
+ }
330
+
331
+ async function handleAgentTokens(
332
+ req: IncomingMessage,
333
+ res: ServerResponse,
334
+ pathSegments: string[],
335
+ queryParams: URLSearchParams,
336
+ ): Promise<boolean> {
337
+ const matched = AGENT_TOKEN_ROUTES.find((def) => def.match(req.method, pathSegments));
338
+ if (!matched) return false;
339
+ if (!(await ensureAgentTokenManager(req, res, pathSegments[2]!))) return true;
340
+ res.setHeader("Cache-Control", "no-store");
341
+
342
+ if (matched === mintAgentTokenRoute) {
343
+ const parsed = await mintAgentTokenRoute.parse(req, res, pathSegments, queryParams);
344
+ if (!parsed) return true;
345
+ const agentId = parsed.params.id;
346
+ const result = await getDbClient()
347
+ .transaction(async () => {
348
+ let agentCreated = false;
349
+ if (!(await getAgentById(agentId))) {
350
+ if (!parsed.body?.name) return { status: 404 as const };
351
+ // Same defaults as a worker's first registration; it is offline until it connects.
352
+ await createAgent({
353
+ id: agentId,
354
+ name: parsed.body.name,
355
+ isLead: false,
356
+ status: "offline",
357
+ capabilities: [],
358
+ maxTasks: 1,
359
+ });
360
+ agentCreated = true;
361
+ }
362
+ return {
363
+ status: 200 as const,
364
+ agentCreated,
365
+ token: await mintAgentToken(agentId, parsed.body?.ttlMs),
366
+ };
367
+ })
368
+ .catch((error) => {
369
+ if (error instanceof AgentTokenExistsError) return { status: 409 as const };
370
+ throw error;
371
+ });
372
+ if (result.status === 404) {
373
+ jsonError(res, "Agent not found; pass name to create it", 404);
374
+ return true;
375
+ }
376
+ if (result.status === 409) {
377
+ jsonError(res, "Agent already holds an active worker token; rotate or revoke it", 409);
378
+ return true;
379
+ }
380
+ mintAgentTokenRoute.respond(res, 200, {
381
+ agentId,
382
+ ...result.token,
383
+ agentCreated: result.agentCreated,
384
+ });
385
+ return true;
386
+ }
387
+
388
+ if (matched === rotateAgentTokenRoute) {
389
+ const parsed = await rotateAgentTokenRoute.parse(req, res, pathSegments, queryParams);
390
+ if (!parsed) return true;
391
+ const graceSec = parsed.body?.graceSec ?? AGENT_TOKEN_ROTATE_GRACE_SEC;
392
+ const token = await rotateAgentToken(parsed.params.id, graceSec * 1000, parsed.body?.ttlMs);
393
+ if (!token) {
394
+ jsonError(res, "Agent holds no active worker token", 404);
395
+ return true;
396
+ }
397
+ rotateAgentTokenRoute.respond(res, 200, { agentId: parsed.params.id, ...token });
398
+ return true;
399
+ }
400
+
401
+ const parsed = await revokeAgentTokenRoute.parse(req, res, pathSegments, queryParams);
402
+ if (!parsed) return true;
403
+ revokeAgentTokenRoute.respond(res, 200, { revoked: await revokeAgentTokens(parsed.params.id) });
404
+ return true;
405
+ }
406
+
174
407
  const listSessions = route({
175
408
  method: "get",
176
409
  path: "/api/sessions",
@@ -309,9 +542,11 @@ export async function handleSessions(
309
542
  queryParams: URLSearchParams,
310
543
  myAgentId?: string,
311
544
  ): Promise<boolean> {
545
+ if (await handleAgentTokens(req, res, pathSegments, queryParams)) return true;
546
+
312
547
  if (mintSessionTokenRoute.match(req.method, pathSegments)) {
313
548
  const auth = getRequestAuth(req);
314
- if (auth?.kind !== "operator" && !(auth?.kind === "agent" && auth.runtimeId)) {
549
+ if (auth?.kind !== "operator" && !(auth?.kind === "agent" && auth.tokenKind !== "task")) {
315
550
  jsonError(res, "Unauthorized", 401);
316
551
  return true;
317
552
  }
@@ -334,7 +569,7 @@ export async function handleSessions(
334
569
  return {
335
570
  token: await mintSessionToken(agentId, taskId!, ttlMs!, {
336
571
  tokenId: auth.tokenId,
337
- runtimeId: auth.runtimeId!,
572
+ runtimeId: auth.runtimeId,
338
573
  }),
339
574
  };
340
575
  }
@@ -365,7 +600,10 @@ export async function handleSessions(
365
600
  const auth = getRequestAuth(req);
366
601
  if (
367
602
  auth?.kind !== "operator" &&
368
- !(isRuntimePoolsEnabled() && auth?.kind === "agent" && auth.runtimeId)
603
+ !(
604
+ auth?.kind === "agent" &&
605
+ (auth.tokenKind === "agent" || (auth.tokenKind === "runtime" && isRuntimePoolsEnabled()))
606
+ )
369
607
  ) {
370
608
  jsonError(res, "Unauthorized", 401);
371
609
  return true;
@@ -375,8 +613,9 @@ export async function handleSessions(
375
613
  const revoked = await getDbClient().transaction(async (client) => {
376
614
  if (auth.kind === "agent") {
377
615
  const child = await client.get(
378
- "SELECT id FROM session_tokens WHERE id = ? AND agentId = ? AND parentRuntimeId = ? AND taskId IS NOT NULL",
379
- [parsed.params.tokenId, auth.agentId, auth.runtimeId!],
616
+ `SELECT id FROM session_tokens WHERE id = ? AND agentId = ? AND taskId IS NOT NULL
617
+ AND ${auth.runtimeId ? "parentRuntimeId" : "parentTokenId"} = ?`,
618
+ [parsed.params.tokenId, auth.agentId, auth.runtimeId ?? auth.tokenId],
380
619
  );
381
620
  if (!child) return false;
382
621
  }
package/src/http/tasks.ts CHANGED
@@ -87,6 +87,7 @@ import { buildTaskRequestedBy, TaskRequestedBySchema } from "./requested-by";
87
87
  import { bodyTask, route } from "./route-def";
88
88
  import {
89
89
  canReadSession,
90
+ denyParentAttach,
90
91
  readableByFor,
91
92
  resolveSessionActor,
92
93
  SESSION_READ_DENIED,
@@ -779,16 +780,6 @@ const updateTaskTitleRoute = route({
779
780
  },
780
781
  });
781
782
 
782
- /** True unless the request acts for a user who lacks write access to the task's session. */
783
- async function hasSessionWrite(
784
- req: IncomingMessage,
785
- myAgentId: string | undefined,
786
- taskId: string,
787
- ): Promise<boolean> {
788
- const access = await sessionAccessFor(await resolveSessionActor(req, myAgentId), taskId);
789
- return hasSessionAccess(access, "write");
790
- }
791
-
792
783
  async function canSteerTask(
793
784
  req: IncomingMessage,
794
785
  myAgentId: string | undefined,
@@ -1069,29 +1060,10 @@ export async function handleTasks(
1069
1060
  ? await getTaskById(parsed.body.parentTaskId)
1070
1061
  : null;
1071
1062
  // The child inherits the parent's thread context, so an agent caller must own or
1072
- // have created the parent. Operator and user callers keep their access.
1063
+ // have created the parent, and a follow-up in someone else's session needs a
1064
+ // write share. The same gate guards app actions that chain under a task.
1073
1065
  const principal = await resolveTaskWritePrincipal(req, myAgentId);
1074
- if (
1075
- parentTask &&
1076
- !can({
1077
- principal,
1078
- verb: "task.parent.attach",
1079
- resource: {
1080
- kind: "task",
1081
- taskId: parentTask.id,
1082
- requestedByUserId: parentTask.requestedByUserId,
1083
- creatorAgentId: parentTask.creatorAgentId,
1084
- agentId: parentTask.agentId,
1085
- },
1086
- source: "http",
1087
- }).allow
1088
- ) {
1089
- jsonError(res, `You don't have context for parent task ${parentTask.id}.`, 403);
1090
- return true;
1091
- }
1092
- // A follow-up in someone else's session needs a write share.
1093
- if (parentTask && !(await hasSessionWrite(req, myAgentId, parentTask.id))) {
1094
- jsonError(res, SESSION_WRITE_DENIED, 403);
1066
+ if (parentTask && (await denyParentAttach(req, res, myAgentId, parentTask, principal))) {
1095
1067
  return true;
1096
1068
  }
1097
1069
  // A chat-sdk contextKey binds the task to a chat thread that its assignee and creator
@@ -1,15 +1,21 @@
1
+ // `/unstable` keeps the full ACP v1 surface the 1.x default import carried:
2
+ // turn `usage` on the prompt response is unstable, and the stable entrypoint
3
+ // does not type it. Both entrypoints speak ACP v1 on the wire.
1
4
  import {
2
- type Client,
3
- ClientSideConnection,
5
+ client as acpClient,
6
+ type ClientContext,
4
7
  type McpServer,
8
+ methods,
5
9
  ndJsonStream,
6
10
  PROTOCOL_VERSION,
7
11
  type RequestPermissionRequest,
8
12
  type RequestPermissionResponse,
9
13
  type SessionConfigOption,
10
14
  type SessionNotification,
15
+ type SetSessionConfigOptionRequest,
16
+ type SetSessionConfigOptionResponse,
11
17
  type Usage,
12
- } from "@agentclientprotocol/sdk";
18
+ } from "@agentclientprotocol/sdk/unstable";
13
19
  import pkg from "../../package.json";
14
20
  import type { AcpSessionConfigOption } from "../types";
15
21
  import { mintAcpSessionToken, revokeAcpSessionToken } from "../utils/acp-session-token";
@@ -154,7 +160,7 @@ function boundedAcpLogScalar(value: unknown): unknown {
154
160
  return typeof value === "string" ? value.slice(0, 500) : value;
155
161
  }
156
162
 
157
- class SwarmAcpClient implements Client {
163
+ class SwarmAcpClient {
158
164
  /** Latest cumulative USD cost the target reported for the session. */
159
165
  reportedCostUsd: number | null = null;
160
166
 
@@ -199,7 +205,7 @@ class ACPSession implements ProviderSession {
199
205
  private ephemeralTokenId: string | null = null;
200
206
 
201
207
  constructor(
202
- private readonly connection: ClientSideConnection,
208
+ private readonly agent: ClientContext,
203
209
  private readonly client: SwarmAcpClient,
204
210
  private readonly process: Bun.Subprocess<"pipe", "pipe", "pipe">,
205
211
  private readonly config: ProviderSessionConfig,
@@ -234,7 +240,7 @@ class ACPSession implements ProviderSession {
234
240
  if (this.aborted) return;
235
241
  this.aborted = true;
236
242
  try {
237
- await this.connection.cancel({ sessionId: this.sessionId });
243
+ await this.agent.notify(methods.agent.session.cancel, { sessionId: this.sessionId });
238
244
  await Promise.race([this.completionPromise, Bun.sleep(ACP_CANCEL_SETTLE_MS)]);
239
245
  } catch (err) {
240
246
  this.emit({
@@ -282,7 +288,7 @@ class ACPSession implements ProviderSession {
282
288
  private async runPrompt(): Promise<void> {
283
289
  let result: ProviderResult;
284
290
  try {
285
- const response = await this.connection.prompt({
291
+ const response = await this.agent.request(methods.agent.session.prompt, {
286
292
  sessionId: this.sessionId,
287
293
  prompt: [{ type: "text", text: this.config.prompt }],
288
294
  });
@@ -441,11 +447,16 @@ export class ACPAdapter implements ProviderAdapter {
441
447
  else preSessionEvents.push(event);
442
448
  }, target);
443
449
  const stream = ndJsonStream(fileSinkWritableStream(proc.stdin), proc.stdout);
444
- const connection = new ClientSideConnection(() => client, stream);
450
+ const agent = acpClient()
451
+ .onRequest(methods.client.session.requestPermission, ({ params }) =>
452
+ client.requestPermission(params),
453
+ )
454
+ .onNotification(methods.client.session.update, ({ params }) => client.sessionUpdate(params))
455
+ .connect(stream).agent;
445
456
 
446
457
  let ephemeralToken: { tokenId: string; plaintext: string } | null = null;
447
458
  try {
448
- await connection.initialize({
459
+ await agent.request(methods.agent.initialize, {
449
460
  protocolVersion: PROTOCOL_VERSION,
450
461
  clientInfo: { name: "agent-swarm", version: pkg.version },
451
462
  clientCapabilities: { session: { configOptions: { boolean: {} } } },
@@ -473,7 +484,7 @@ export class ACPAdapter implements ProviderAdapter {
473
484
  // shape for an ACP agent with no network route to the swarm API. Gated on
474
485
  // `mcpCapabilities.acp` and UNSTABLE; not adopted here.
475
486
  const sessionMeta = target.sessionMeta?.(config);
476
- const newSession = await connection.newSession({
487
+ const newSession = await agent.request(methods.agent.session.new, {
477
488
  ...(sessionMeta ? { _meta: sessionMeta } : {}),
478
489
  cwd: config.cwd,
479
490
  mcpServers: [
@@ -491,13 +502,16 @@ export class ACPAdapter implements ProviderAdapter {
491
502
  ],
492
503
  });
493
504
  const configOptions = await applyConfiguredOptions(
494
- connection,
505
+ {
506
+ setSessionConfigOption: (params) =>
507
+ agent.request(methods.agent.session.setConfigOption, params),
508
+ },
495
509
  newSession.sessionId,
496
510
  newSession.configOptions ?? [],
497
511
  target.configuredOptions(config),
498
512
  );
499
513
  session = new ACPSession(
500
- connection,
514
+ agent,
501
515
  client,
502
516
  proc,
503
517
  config,
@@ -537,8 +551,14 @@ export class ACPAdapter implements ProviderAdapter {
537
551
  }
538
552
  }
539
553
 
554
+ export interface AcpConfigOptionSetter {
555
+ setSessionConfigOption(
556
+ params: SetSessionConfigOptionRequest,
557
+ ): Promise<SetSessionConfigOptionResponse>;
558
+ }
559
+
540
560
  export async function applyConfiguredOptions(
541
- connection: Pick<ClientSideConnection, "setSessionConfigOption">,
561
+ connection: AcpConfigOptionSetter,
542
562
  sessionId: string,
543
563
  advertised: SessionConfigOption[],
544
564
  configured: Record<string, string | boolean>,
@@ -3,7 +3,7 @@ import type {
3
3
  SessionNotification,
4
4
  SessionUpdate,
5
5
  ToolCallUpdate,
6
- } from "@agentclientprotocol/sdk";
6
+ } from "@agentclientprotocol/sdk/unstable";
7
7
  import type { ProviderEvent } from "./types";
8
8
 
9
9
  function contentToText(content: ContentBlock): string {
@@ -44,7 +44,7 @@ import type {
44
44
  * default; the image installs exactly this version and the per-task settings
45
45
  * set `amp.updates.mode: "disabled"`. Bump it deliberately, with a live run.
46
46
  */
47
- export const AMP_PACKAGE = "@ampcode/cli@0.0.1792470027-g8c393d";
47
+ export const AMP_PACKAGE = "@ampcode/cli@0.0.1792506219-g025e7e";
48
48
 
49
49
  /** MCP server name. Amp exposes its tools as `agent_swarm` / `store_progress` (dashes become underscores). */
50
50
  const AMP_MCP_SERVER_NAME = "agent-swarm";
@@ -364,7 +364,7 @@ export async function persistCodexOAuth(
364
364
  * once it was issued more than that long ago (see {@link isStaleByAge}) — on
365
365
  * top of the near-expiry skew above, not instead of it. This is how the
366
366
  * locked keep-warm sweep (`POST /api/oauth/keep-warm/codex`) reuses this same
367
- * function to refresh slots proactively (~weekly, `maxAgeMs` ≈ 7 days) rather
367
+ * function to refresh slots proactively (`maxAgeMs` default 5 days) rather
368
368
  * than waiting for a task to draw a slot within `REFRESH_SKEW_MS` of expiry.
369
369
  * Default behavior (opts omitted) is unchanged.
370
370
  *
@@ -318,7 +318,7 @@ export const LEGACY_POLICY = {
318
318
  "config.write.any": leadOnly,
319
319
  "config.delete.any": leadOnly,
320
320
  "config.read.secrets": leadOnly,
321
- // HTTP runtime tokens can read their own resolved config in src/http/config.ts.
321
+ // HTTP runtime and agent tokens can read their own resolved config in src/http/config.ts.
322
322
  // Lead tokens retain config.read.secrets. This exception does not grant config administration.
323
323
  "models.catalog.write": leadOrOperator,
324
324
  "models.harness-support.write": agentOrOperator,
@@ -356,6 +356,8 @@ export const LEGACY_POLICY = {
356
356
  "runtime_pool.write": operatorOrUser,
357
357
  "runtime_pool.bind": leadOrOperatorOrUser,
358
358
  "runtime_controller.write": operatorOrUser,
359
+ // A worker token stands in for the swarm API key on a worker, so only humans mint it.
360
+ "agent.token.manage": operatorOrUser,
359
361
  "extension.write": extensionWrite,
360
362
  "extension.activate": extensionActivation,
361
363
  "extension.install.inline": leadOrOperatorOrUser,
@@ -33,6 +33,10 @@ export const PERMISSIONS = {
33
33
  description: "View or diff another agent's context history.",
34
34
  namespace: "agent",
35
35
  },
36
+ "agent.token.manage": {
37
+ description: "Mint, rotate, or revoke an agent-bound worker token.",
38
+ namespace: "agent",
39
+ },
36
40
  "task.cancel.any": {
37
41
  description: "Cancel any task (beyond tasks the caller created).",
38
42
  namespace: "task",