@desplega.ai/agent-swarm 2.4.2 → 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 (183) hide show
  1. package/dist/{acp-adapter-dv7hmx4r.js → acp-adapter-thq04adh.js} +4 -5
  2. package/dist/{actions-51nc1gny.js → actions-j6ywh6x5.js} +18 -18
  3. package/dist/{amp-adapter-abp5tdx8.js → amp-adapter-k8b5xmj8.js} +3 -3
  4. package/dist/{app-9h14qvr6.js → app-rb7vc02s.js} +3 -3
  5. package/dist/{assistant-wv1gz3mt.js → assistant-ftta1zjf.js} +20 -20
  6. package/dist/{boot-reembed-6yn53ms9.js → boot-reembed-srp6v6af.js} +1 -1
  7. package/dist/{boot-reembed-7fcka4z5.js → boot-reembed-xns7h5h9.js} +1 -1
  8. package/dist/{boot-scrub-sweep-pm1580ax.js → boot-scrub-sweep-3qqrh2mz.js} +3 -3
  9. package/dist/{boot-seeds-g5hfc1x8.js → boot-seeds-9ks8hce7.js} +4 -4
  10. package/dist/{claude-adapter-gf3kxhf0.js → claude-adapter-k5ksyres.js} +4 -4
  11. package/dist/{claude-managed-setup-h00hsgtz.js → claude-managed-setup-n5a5eqks.js} +1 -1
  12. package/dist/{claude-sdk-session-s2k1qd8y.js → claude-sdk-session-2j3j4kwn.js} +9822 -9749
  13. package/dist/{cli-pkht5hap.js → cli-1d70crsy.js} +2 -2
  14. package/dist/{cli-r99kc9yq.js → cli-1ephgwnq.js} +3 -3
  15. package/dist/{cli-7225gwj7.js → cli-1m17dc9d.js} +1 -1
  16. package/dist/{cli-bcj753sq.js → cli-2v045xyf.js} +3 -3
  17. package/dist/{cli-h38rjv87.js → cli-2y91wnbh.js} +12 -7
  18. package/dist/{cli-sv5gb0yb.js → cli-2zqpkvtt.js} +1 -1
  19. package/dist/{cli-xbzvet96.js → cli-36ckw0wx.js} +5 -4
  20. package/dist/{cli-8sxqsbbq.js → cli-37yb7307.js} +1 -1
  21. package/dist/{cli-hyfw2j1e.js → cli-3cgngyd8.js} +11 -11
  22. package/dist/{cli-a4gvwttf.js → cli-44159k28.js} +2 -2
  23. package/dist/{cli-7awf1fp8.js → cli-4dg55wsk.js} +3 -3
  24. package/dist/{cli-mhywgrrz.js → cli-4sydckyj.js} +1 -1
  25. package/dist/{cli-y2x52gs2.js → cli-51y09c6p.js} +20 -17
  26. package/dist/{cli-ahtpy9kf.js → cli-55qmbp6z.js} +10 -5
  27. package/dist/{cli-pw7v69vp.js → cli-625tb7aq.js} +1 -1
  28. package/dist/{cli-x5mq1fyq.js → cli-74c1wpm0.js} +3 -3
  29. package/dist/{cli-b1dfr1m4.js → cli-7a6p4e65.js} +1 -1
  30. package/dist/{cli-hqhzg1qq.js → cli-82d3nkc3.js} +1 -1
  31. package/dist/{cli-nmqdc2t3.js → cli-9fhkkpj5.js} +78 -12
  32. package/dist/{cli-h93c9vb3.js → cli-amyhm3gj.js} +1 -1
  33. package/dist/{cli-0qvtffe9.js → cli-b8k7epj1.js} +6 -6
  34. package/dist/{cli-1pa3rz69.js → cli-ba8ne1es.js} +6 -1
  35. package/dist/{cli-g0hgtz4p.js → cli-bnz0r3kc.js} +3 -3
  36. package/dist/{cli-0hyhdkdk.js → cli-cebvbc9t.js} +4 -1
  37. package/dist/{cli-efaqv1c7.js → cli-cynvc9fj.js} +35 -7
  38. package/dist/{cli-8zwe9mpk.js → cli-dqb4n629.js} +10 -10
  39. package/dist/{cli-7a91w22y.js → cli-e8xyjvfc.js} +1 -1
  40. package/dist/{cli-s6vr7r2d.js → cli-esxerzg7.js} +1 -1
  41. package/dist/{cli-w9g4sz81.js → cli-gf2850q7.js} +1 -1
  42. package/dist/{cli-10hhg0km.js → cli-gqvh8d8a.js} +7 -7
  43. package/dist/{cli-t30yj95q.js → cli-hj3debgw.js} +1 -1
  44. package/dist/{cli-fpmsqq59.js → cli-nc35pxb7.js} +3 -3
  45. package/dist/{cli-d3qqdy4c.js → cli-p20r8w42.js} +16 -14
  46. package/dist/{cli-shvfedvm.js → cli-pccfmtb5.js} +5 -5
  47. package/dist/{cli-02qm7src.js → cli-q6r7t299.js} +1037 -299
  48. package/dist/{cli-2wag8973.js → cli-qeaq3j6t.js} +1 -1
  49. package/dist/{cli-nn47d7e8.js → cli-qkhc5aqy.js} +15 -15
  50. package/dist/{cli-faypxz7q.js → cli-t7cyef5g.js} +4 -4
  51. package/dist/{cli-z4wd2ftv.js → cli-te6b5wvg.js} +149 -184
  52. package/dist/{cli-5rk36sjf.js → cli-v3yr2gbg.js} +119 -44
  53. package/dist/{cli-239yjk27.js → cli-vdm20wej.js} +5 -5
  54. package/dist/{cli-zfg9enh9.js → cli-vfha21c9.js} +6 -1
  55. package/dist/{cli-rdyw3mct.js → cli-vq97g1jq.js} +6 -5
  56. package/dist/{cli-n85dqg6d.js → cli-w6nadkh4.js} +4 -4
  57. package/dist/{cli-99jez72r.js → cli-w9etnnxk.js} +6 -1
  58. package/dist/{cli-saw9a6f6.js → cli-xc27fwzg.js} +2 -2
  59. package/dist/{cli-ftyypbg5.js → cli-xj0re5tm.js} +1 -1
  60. package/dist/{cli-fyzjmseb.js → cli-zrk6jxv8.js} +1 -1
  61. package/dist/{cli-y8n5k515.js → cli-zrwpctp4.js} +28 -28
  62. package/dist/cli.js +44 -19
  63. package/dist/{codex-adapter-ttygw382.js → codex-adapter-968xwfy7.js} +2 -2
  64. package/dist/{codex-hook-x7nejyrr.js → codex-hook-e2he7aca.js} +2 -2
  65. package/dist/{codex-session-runner-60b58vv3.js → codex-session-runner-ht4z99fa.js} +2 -2
  66. package/dist/{commands-edmdwa4x.js → commands-pcymaeg9.js} +3 -3
  67. package/dist/{controller-m263fg43.js → controller-fpjyk46r.js} +3 -3
  68. package/dist/{controllers-connect-5fym8sw1.js → controllers-connect-mwzx1fh6.js} +19 -19
  69. package/dist/{cursor-adapter-cz6nm6mj.js → cursor-adapter-cp4s2e2f.js} +2 -2
  70. package/dist/{db-2y0mgstc.js → db-sgd12bt9.js} +3 -3
  71. package/dist/{db-nn0hcmx5.js → db-xsjg9jqq.js} +2 -2
  72. package/dist/{dispatcher-0a9wwjcx.js → dispatcher-x0jfw2c9.js} +17 -17
  73. package/dist/{dsh-adapter-3n3net33.js → dsh-adapter-jrq7ma7h.js} +2 -2
  74. package/dist/{e2b-thdcxtjd.js → e2b-16d6ch39.js} +1 -1
  75. package/dist/{e2b-7am6tmq6.js → e2b-zn0b4w5w.js} +1 -1
  76. package/dist/{external-channel-zhvt95n3.js → external-channel-8njj4h3b.js} +2 -2
  77. package/dist/{grok-adapter-qnr1hp2y.js → grok-adapter-kkr97v2f.js} +5 -6
  78. package/dist/handlers-0pvee9nw.js +39 -0
  79. package/dist/{heartbeat-gz97r7ac.js → heartbeat-wd03zhn0.js} +20 -20
  80. package/dist/{hook-1mmdgjqs.js → hook-f0vghj42.js} +10 -5
  81. package/dist/{hook-x8rthca4.js → hook-xzj2ts2p.js} +2 -2
  82. package/dist/{http-r8f85xr9.js → http-4hwxv62p.js} +261 -123
  83. package/dist/{index-va3y63es.js → index-6srwvd5b.js} +1 -2
  84. package/dist/{index-y7rbcrc1.js → index-97q4xn03.js} +20 -11
  85. package/dist/{index-s98037v0.js → index-cbxkmfc5.js} +1 -2
  86. package/dist/{index-vzvfqzfj.js → index-gdh8jrdm.js} +17 -17
  87. package/dist/{index-aex0eba9.js → index-hg0x2cdv.js} +17 -17
  88. package/dist/index-thm0y6a8.js +41 -0
  89. package/dist/{index-smxyk04s.js → index-ww2vznkp.js} +1 -1
  90. package/dist/{keepalive-64c0f34c.js → keepalive-hrh7xnr9.js} +3 -3
  91. package/dist/{lead-b1da0f97.js → lead-ee8vazz2.js} +30 -31
  92. package/dist/{maintenance-mnxpar12.js → maintenance-e8wnwnqx.js} +1 -1
  93. package/dist/{oauth-refresh-sweep-36hs2v4h.js → oauth-refresh-sweep-zm0yj4sj.js} +2 -2
  94. package/dist/{onboard-xes8de61.js → onboard-dtwcq603.js} +3 -3
  95. package/dist/{opencode-adapter-3s7ax02e.js → opencode-adapter-x4tqzhkh.js} +3 -3
  96. package/dist/{otel-impl-fzc57kcq.js → otel-impl-w3fbhpn0.js} +1 -1
  97. package/dist/{pi-mono-adapter-ytp7gz8b.js → pi-mono-adapter-mcfkn9vf.js} +3 -3
  98. package/dist/{pricing-refresh-1nek2w7b.js → pricing-refresh-j38dehfs.js} +3 -3
  99. package/dist/{rbac-roles-ttyfaep2.js → rbac-roles-ae8yttq4.js} +3 -3
  100. package/dist/{rbac-roles-sqefb5hw.js → rbac-roles-ejxx9b35.js} +2 -2
  101. package/dist/{render-v2-vb527jwn.js → render-v2-n16n0phe.js} +3 -3
  102. package/dist/{resume-jcdzg1bn.js → resume-z0fgjc38.js} +5 -5
  103. package/dist/{session-access-zsp4wsp0.js → session-access-cv3mq6mc.js} +6 -4
  104. package/dist/{setup-1nb2zqd3.js → setup-mafhj3yt.js} +1 -1
  105. package/dist/{work-objects-61w08fwr.js → work-objects-8b86mgjp.js} +21 -21
  106. package/dist/{worker-c1e68hpa.js → worker-16s75hm5.js} +30 -31
  107. package/dist/worker-token-xf11yyds.js +136 -0
  108. package/openapi.json +295 -12
  109. package/package.json +5 -5
  110. package/src/apps/action-runner.ts +4 -0
  111. package/src/apps/catalog.generated.json +3 -0
  112. package/src/apps/page-validator.ts +8 -2
  113. package/src/be/db.ts +7 -4
  114. package/src/be/migrations/231_slack_tasks_last_updated_index.sql +5 -0
  115. package/src/be/migrations/232_agent_session_tokens.sql +41 -0
  116. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +10 -1
  117. package/src/be/swarm-config-guard.ts +16 -0
  118. package/src/be/users.ts +169 -12
  119. package/src/cli.tsx +24 -0
  120. package/src/commands/worker-token.ts +164 -0
  121. package/src/github/handlers.ts +6 -5
  122. package/src/hooks/hook.ts +2 -1
  123. package/src/http/apps.ts +43 -5
  124. package/src/http/codex-oauth-keep-warm.ts +27 -9
  125. package/src/http/config.ts +5 -3
  126. package/src/http/core.ts +2 -0
  127. package/src/http/session-access.ts +51 -2
  128. package/src/http/sessions.ts +249 -10
  129. package/src/http/tasks.ts +4 -32
  130. package/src/providers/acp-adapter.ts +33 -13
  131. package/src/providers/acp-swarm-events.ts +1 -1
  132. package/src/providers/amp-adapter.ts +1 -1
  133. package/src/providers/codex-adapter.ts +4 -4
  134. package/src/providers/codex-oauth/storage.ts +1 -1
  135. package/src/providers/opencode-adapter.ts +2 -2
  136. package/src/providers/pi-mono-extension.ts +2 -1
  137. package/src/rbac/legacy-policy.ts +3 -1
  138. package/src/rbac/permissions.ts +4 -0
  139. package/src/tests/acp-adapter.test.ts +10 -23
  140. package/src/tests/acp-fake-agent.ts +36 -0
  141. package/src/tests/acp-swarm-events.test.ts +1 -1
  142. package/src/tests/agent-session-tokens.test.ts +452 -0
  143. package/src/tests/apps-action-parent-task.test.ts +295 -0
  144. package/src/tests/claude-stop-hook.test.ts +47 -0
  145. package/src/tests/codex-adapter.test.ts +42 -0
  146. package/src/tests/codex-oauth-keep-warm.test.ts +85 -1
  147. package/src/tests/config-session-auth.test.ts +14 -0
  148. package/src/tests/docs-index-footprint.test.js +2 -2
  149. package/src/tests/env-flag.test.ts +15 -1
  150. package/src/tests/github-handlers-cancel-config.test.ts +29 -8
  151. package/src/tests/github-handlers-inline-comments.test.ts +9 -2
  152. package/src/tests/grok-adapter.test.ts +3 -3
  153. package/src/tests/heartbeat-jev-gate.test.ts +23 -0
  154. package/src/tests/migration-232-agent-session-tokens.test.ts +67 -0
  155. package/src/tests/rbac-engine.test.ts +1 -0
  156. package/src/tests/runtime-child-token-cleanup.test.ts +3 -3
  157. package/src/tests/script-connections.test.ts +4 -0
  158. package/src/tests/session-route-posture.test.ts +2 -0
  159. package/src/tests/skill-script-refs.test.ts +63 -0
  160. package/src/tests/slack-render-v2-query-plans.test.ts +147 -0
  161. package/src/tests/worker-token-cli.test.ts +76 -0
  162. package/src/tests/workflow-executors.test.ts +4 -2
  163. package/src/tests/workflow-system-one-extraction.test.ts +805 -0
  164. package/src/types.ts +2 -1
  165. package/src/utils/env-flag.ts +9 -0
  166. package/src/utils/request-auth-context.ts +2 -1
  167. package/src/workflows/definition.ts +7 -1
  168. package/src/workflows/engine.ts +18 -12
  169. package/src/workflows/executors/index.ts +1 -0
  170. package/src/workflows/executors/registry.ts +2 -0
  171. package/src/workflows/executors/system-one-decision.ts +56 -255
  172. package/src/workflows/executors/system-one-extract-wire.ts +57 -0
  173. package/src/workflows/executors/system-one-extraction.ts +830 -0
  174. package/src/workflows/executors/system-one-providers.ts +277 -110
  175. package/src/workflows/executors/system-one-transport.ts +264 -0
  176. package/templates/official/lead/HEARTBEAT.md +1 -1
  177. package/templates/skills/apps/SKILL.md +4 -1
  178. package/templates/skills/apps/content.md +4 -1
  179. package/templates/skills/code-quality/content.md +9 -14
  180. package/templates/skills/heartbeat-runbook/content.md +3 -3
  181. package/dist/cli-v1sjhn52.js +0 -7
  182. package/dist/handlers-dkcarkd0.js +0 -39
  183. package/dist/index-5jecgp3n.js +0 -41
package/src/http/apps.ts CHANGED
@@ -62,14 +62,16 @@ import {
62
62
  rollbackApp,
63
63
  snapshotApp,
64
64
  } from "../apps/version";
65
- import { getAgentById, getAppVersion, getAppVersions } from "../be/db";
65
+ import { getAgentById, getAppVersion, getAppVersions, getTaskById } from "../be/db";
66
66
  import type { RbacPrincipal, RbacResource } from "../rbac";
67
67
  import { can } from "../rbac";
68
68
  import { AgentTaskStatusSchema } from "../types";
69
69
  import { getRequestAuth } from "../utils/request-auth-context";
70
70
  import { scrubObject } from "../utils/secret-scrubber";
71
71
  import { resolveHttpFavoriteOwner } from "./favorite-owner";
72
- import { route } from "./route-def";
72
+ import { agentFirstPrincipal } from "./request-principal";
73
+ import { bodyTask, route } from "./route-def";
74
+ import { denyParentAttach } from "./session-access";
73
75
  import { BODY_TOO_LARGE, enforceContentLengthCap, json, jsonError } from "./utils";
74
76
 
75
77
  const MAX_APP_BODY_BYTES = 5 * 1024 * 1024;
@@ -618,17 +620,27 @@ const runActionRoute = route({
618
620
  method: "post",
619
621
  path: "/api/apps/{id}/actions/{name}",
620
622
  pattern: ["api", "apps", null, "actions", null],
623
+ // A task-kind action may chain under `body.parentTaskId`: a user caller needs
624
+ // write access to that session, as for POST /api/tasks.
625
+ session: bodyTask("parentTaskId"),
621
626
  summary: "Run a custom app action",
622
627
  description: "Runs the saved script, or creates the agent task, named by the app definition.",
623
628
  tags: ["Apps"],
624
629
  params: z.object({ id: z.string().min(1), name: AppNameSchema }),
625
- body: z.object({ input: z.record(z.string(), z.unknown()).optional() }),
630
+ body: z.object({
631
+ input: z.record(z.string(), z.unknown()).optional(),
632
+ /** Chains the new task off an existing one. Task-kind actions only: a script or sync action rejects it (400). */
633
+ parentTaskId: z.string().min(1).optional(),
634
+ }),
626
635
  responses: {
627
636
  200: { description: "Action invoked", schema: runActionResultSchema },
628
- 400: { description: "Invalid action input or stale script reference" },
637
+ 400: {
638
+ description:
639
+ "Invalid action input, stale script reference, or parentTaskId on a non-task action",
640
+ },
629
641
  403: { description: "Permission denied" },
630
642
  409: { description: "App definition needs repair" },
631
- 404: { description: "App or action not found" },
643
+ 404: { description: "App, action, or parent task not found" },
632
644
  },
633
645
  rbac: { permission: "app.use" },
634
646
  });
@@ -1382,10 +1394,36 @@ export async function handleApps(
1382
1394
  return true;
1383
1395
  }
1384
1396
  if (definitionNeedsRepair(res, app)) return true;
1397
+ if (parsed.body.parentTaskId) {
1398
+ // `parentTaskId` only means something to a task-kind action: a script or
1399
+ // sync action rejects it rather than silently dropping it. An unknown
1400
+ // action keeps `runAppAction`'s 404.
1401
+ const actions = app.definition.actions;
1402
+ const action =
1403
+ actions && Object.hasOwn(actions, parsed.params.name)
1404
+ ? actions[parsed.params.name]
1405
+ : undefined;
1406
+ if (action && action.kind !== "task") {
1407
+ jsonError(res, "parentTaskId is only supported for task-kind actions", 400);
1408
+ return true;
1409
+ }
1410
+ if (action) {
1411
+ const parentTask = await getTaskById(parsed.body.parentTaskId);
1412
+ if (!parentTask) {
1413
+ jsonError(res, "parent task not found", 404);
1414
+ return true;
1415
+ }
1416
+ // The same gate as POST /api/tasks: without it an app action would
1417
+ // chain under any task past `task.parent.attach`.
1418
+ const principal = await agentFirstPrincipal(req, myAgentId);
1419
+ if (await denyParentAttach(req, res, myAgentId, parentTask, principal)) return true;
1420
+ }
1421
+ }
1385
1422
  const output = await runAppAction({
1386
1423
  app,
1387
1424
  name: parsed.params.name,
1388
1425
  input: parsed.body.input,
1426
+ parentTaskId: parsed.body.parentTaskId,
1389
1427
  caller,
1390
1428
  actor,
1391
1429
  });
@@ -12,9 +12,10 @@
12
12
  * when a task happens to draw it within `REFRESH_SKEW_MS` of expiry — which
13
13
  * for an idle/rarely-drawn slot can be never, letting its refresh token sit
14
14
  * unexercised well past OpenAI's ~8-day session-staleness guidance. This
15
- * sweep calls the SAME `getValidCodexOAuth` with `opts.maxAgeMs` ≈ 7 days so
16
- * every slot refreshes on a roughly-weekly cadence regardless of task draw
17
- * frequency — zero logic duplication, all lock/persist/quarantine behavior
15
+ * sweep calls the SAME `getValidCodexOAuth` with `opts.maxAgeMs` (default 5
16
+ * days, `CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS`) so every slot refreshes on a
17
+ * fixed cadence regardless of task draw frequency — zero logic
18
+ * duplication, all lock/persist/quarantine behavior
18
19
  * reused verbatim (see storage.ts doc comment; hand-rolling a refresher here
19
20
  * would re-create the 2026-06-19 race).
20
21
  *
@@ -25,6 +26,7 @@
25
26
  import type { IncomingMessage, ServerResponse } from "node:http";
26
27
  import { z } from "zod";
27
28
  import { getKv } from "../be/db";
29
+ import { CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS_BOUNDS } from "../be/swarm-config-guard";
28
30
  import { deriveCodexKeySuffix } from "../providers/codex-oauth/auth-json.js";
29
31
  import { getValidCodexOAuth, loadAllCodexOAuthSlots } from "../providers/codex-oauth/storage.js";
30
32
  import { getApiKey } from "../utils/api-key";
@@ -32,8 +34,25 @@ import { CODEX_AUTH_WATCH_NAMESPACE, codexAuthBenchMarkerKey } from "../utils/co
32
34
  import { getMcpBaseUrl } from "../utils/constants";
33
35
  import { route } from "./route-def";
34
36
 
35
- /** ~weekly refresh cadence, comfortably inside OpenAI's ~8-day staleness window given the 10-day TTL. */
36
- const KEEP_WARM_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
37
+ /**
38
+ * Default refresh age. The old 7-day value lost a slot whose ChatGPT session
39
+ * OpenAI ended at about 6 days 20 hours, before the first attempt. 5 days
40
+ * leaves 44 hours of margin, so even a daily trigger refreshes by 6 days.
41
+ */
42
+ const DEFAULT_KEEP_WARM_MAX_AGE_HOURS = 120;
43
+
44
+ /**
45
+ * Read on every sweep, never at module load, so a stored override applies on
46
+ * config reload without a restart. A non-integer or out-of-range value falls
47
+ * back to the default; the config API rejects writes outside the same bounds.
48
+ */
49
+ export function keepWarmMaxAgeMs(env: NodeJS.ProcessEnv = process.env): number {
50
+ const raw = env.CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS?.trim();
51
+ const { min, max } = CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS_BOUNDS;
52
+ const value = raw && /^\d+$/.test(raw) ? Number(raw) : Number.NaN;
53
+ const hours = value >= min && value <= max ? value : DEFAULT_KEEP_WARM_MAX_AGE_HOURS;
54
+ return hours * 60 * 60 * 1000;
55
+ }
37
56
 
38
57
  type SlotOutcome =
39
58
  | { slot: number; keySuffix: string; outcome: "warm" | "refreshed" }
@@ -61,7 +80,7 @@ const keepWarmRoute = route({
61
80
  pattern: ["api", "oauth", "keep-warm", "codex"],
62
81
  summary: "Locked keep-warm refresh sweep across all Codex OAuth pool slots",
63
82
  description:
64
- "Enumerates codex_oauth_* slots and refreshes any older than ~7 days through the same locked getValidCodexOAuth path used at task time. Skips slots with a codex-auth-watch bench marker (auth-failure bench or codex-auth-expiry-watch).",
83
+ "Enumerates codex_oauth_* slots and refreshes any older than CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS (default 120 hours) through the same locked getValidCodexOAuth path used at task time. Skips slots with a codex-auth-watch bench marker (auth-failure bench or codex-auth-expiry-watch).",
65
84
  tags: ["OAuth"],
66
85
  responses: {
67
86
  200: {
@@ -82,6 +101,7 @@ export async function handleCodexOAuthKeepWarm(
82
101
 
83
102
  const apiUrl = getMcpBaseUrl();
84
103
  const apiKey = getApiKey();
104
+ const maxAgeMs = keepWarmMaxAgeMs();
85
105
  const slots = await loadAllCodexOAuthSlots(apiUrl, apiKey);
86
106
  const results: SlotOutcome[] = [];
87
107
 
@@ -95,9 +115,7 @@ export async function handleCodexOAuthKeepWarm(
95
115
 
96
116
  try {
97
117
  const beforeAccess = creds.access;
98
- const refreshed = await getValidCodexOAuth(apiUrl, apiKey, slot, {
99
- maxAgeMs: KEEP_WARM_MAX_AGE_MS,
100
- });
118
+ const refreshed = await getValidCodexOAuth(apiUrl, apiKey, slot, { maxAgeMs });
101
119
  if (!refreshed) {
102
120
  results.push({ slot, outcome: "no-credentials" });
103
121
  } else {
@@ -86,7 +86,9 @@ async function requestSecretReadScope(
86
86
  }).allow
87
87
  )
88
88
  return "all";
89
- if (!auth.runtimeId || resolvedScope?.agentId !== auth.agentId) return "none";
89
+ // Runtime and agent tokens stand in for a worker and read their own agent's
90
+ // scope. Task tokens are ACP harness credentials and read no secrets.
91
+ if (auth.tokenKind === "task" || resolvedScope?.agentId !== auth.agentId) return "none";
90
92
  return "self";
91
93
  }
92
94
  if (auth?.kind === "guest") return "none";
@@ -107,7 +109,7 @@ async function resolveSecretsRead(
107
109
  const auth = getRequestAuth(req);
108
110
  return {
109
111
  effectiveIncludeSecrets: includeSecrets && scope !== "none",
110
- includeRepoSecrets: !(auth?.kind === "agent" && auth.runtimeId && scope !== "all"),
112
+ includeRepoSecrets: !(auth?.kind === "agent" && auth.tokenKind !== "task" && scope !== "all"),
111
113
  secretsNote: includeSecrets && scope === "none" ? SECRETS_FORCE_MASK_NOTE : "",
112
114
  };
113
115
  }
@@ -363,7 +365,7 @@ export async function handleConfig(
363
365
  const resolvedScope = {
364
366
  agentId:
365
367
  parsed.query.agentId ||
366
- (auth?.kind === "agent" && auth.runtimeId ? auth.agentId : undefined),
368
+ (auth?.kind === "agent" && auth.tokenKind !== "task" ? auth.agentId : undefined),
367
369
  repoId: parsed.query.repoId || undefined,
368
370
  };
369
371
  const { effectiveIncludeSecrets, includeRepoSecrets, secretsNote } = await resolveSecretsRead(
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
  }