@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
@@ -0,0 +1,295 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ createServer as createHttpServer,
5
+ type IncomingMessage,
6
+ type Server,
7
+ type ServerResponse,
8
+ } from "node:http";
9
+ import { parseAppDefinition } from "../apps/definition";
10
+ import { createApp } from "../apps/store";
11
+ import {
12
+ closeDb,
13
+ createAgent,
14
+ createTask,
15
+ createTaskExtended,
16
+ createUser,
17
+ getTaskById,
18
+ initDb,
19
+ } from "../be/db";
20
+ import { type IdentityActor, mintToken } from "../be/users";
21
+ import { handleApps } from "../http/apps";
22
+ import { resolveHttpRequestAuth } from "../http/auth";
23
+ import { SESSION_WRITE_DENIED } from "../http/session-access";
24
+ import { getPathSegments, parseQueryParams } from "../http/utils";
25
+ import { setRequestAuth } from "../utils/request-auth-context";
26
+ import { listenOnFreePort } from "./test-net";
27
+
28
+ const TEST_DB_PATH = `/private/tmp/test-apps-action-parent-task-${process.pid}.sqlite`;
29
+ const API_KEY = "example-apps-action-parent-task-key";
30
+ const OPERATOR_ACTOR: IdentityActor = { kind: "operator", id: "apps-action-parent-task-test" };
31
+ /** Non-lead worker: the default caller. */
32
+ const AGENT_ID = crypto.randomUUID();
33
+ const LEAD_ID = crypto.randomUUID();
34
+ /** Owns the parent task the worker has no context for. */
35
+ const OTHER_AGENT_ID = crypto.randomUUID();
36
+ const TASK_ACTION = "generateScreen";
37
+ const SYNC_ACTION = "syncNotes";
38
+ const CHAIN_ISSUE = "$row, $rowIndex, $form, and $chain bindings are only allowed in action params";
39
+
40
+ let server: Server;
41
+ let base = "";
42
+ /** A user with no share on anyone else's session. */
43
+ let userToken = "";
44
+ let previousApiKey: string | undefined;
45
+
46
+ function createTestServer(): Server {
47
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
48
+ setRequestAuth(req, await resolveHttpRequestAuth(req, API_KEY));
49
+ res.setHeader("Content-Type", "application/json");
50
+ const pathSegments = getPathSegments(req.url || "");
51
+ const queryParams = parseQueryParams(req.url || "");
52
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
53
+ const handled = await handleApps(req, res, pathSegments, queryParams, myAgentId);
54
+ if (!handled) {
55
+ res.writeHead(404);
56
+ res.end(JSON.stringify({ error: "not found" }));
57
+ }
58
+ });
59
+ }
60
+
61
+ /** Runs an action as an agent (default: the worker) or, with `bearer`, as a token holder. */
62
+ async function runAction<T>(
63
+ appId: string,
64
+ body: Record<string, unknown>,
65
+ options: { agentId?: string; bearer?: string; action?: string } = {},
66
+ ): Promise<{ status: number; body: T }> {
67
+ const action = options.action ?? TASK_ACTION;
68
+ const response = await fetch(`${base}/api/apps/${appId}/actions/${action}`, {
69
+ method: "POST",
70
+ headers: {
71
+ "Content-Type": "application/json",
72
+ ...(options.bearer
73
+ ? { Authorization: `Bearer ${options.bearer}` }
74
+ : { "X-Agent-ID": options.agentId ?? AGENT_ID }),
75
+ },
76
+ body: JSON.stringify(body),
77
+ });
78
+ return { status: response.status, body: (await response.json()) as T };
79
+ }
80
+
81
+ /** A page whose Button chains a task-kind action under the chain scope's leaf task. */
82
+ function chainedButtonPage() {
83
+ return {
84
+ root: "root",
85
+ elements: {
86
+ root: { type: "Container", props: {}, children: ["send"] },
87
+ send: {
88
+ type: "Button",
89
+ props: { label: "Send" },
90
+ on: {
91
+ press: [
92
+ {
93
+ action: "app.action",
94
+ params: {
95
+ name: TASK_ACTION,
96
+ input: { prompt: "next step" },
97
+ parentTaskId: { $chain: "leafTaskId" },
98
+ },
99
+ },
100
+ ],
101
+ },
102
+ },
103
+ },
104
+ };
105
+ }
106
+
107
+ function definitionWith(page: Record<string, unknown>) {
108
+ return {
109
+ models: {
110
+ note: {
111
+ columns: { title: { kind: "string" } },
112
+ // The sync action needs one (model x source) pair to be valid.
113
+ sources: { tracker: { connector: "swarm-tasks", joinKey: "title" } },
114
+ },
115
+ },
116
+ queries: { allNotes: { model: "note" } },
117
+ actions: {
118
+ [TASK_ACTION]: { kind: "task", prompt: "Generate the next screen" },
119
+ // A non-task action, to show parentTaskId is rejected rather than dropped.
120
+ [SYNC_ACTION]: { kind: "sync" },
121
+ },
122
+ pages: { main: page },
123
+ defaultPage: "main",
124
+ };
125
+ }
126
+
127
+ /** Stores the chained-Button app the way the API does: validated first. */
128
+ async function createChainedApp(): Promise<string> {
129
+ const parsed = await parseAppDefinition(definitionWith(chainedButtonPage()));
130
+ if (!parsed.success) throw new Error(JSON.stringify(parsed.issues));
131
+ const app = await createApp({
132
+ name: `Chained ${crypto.randomUUID()}`,
133
+ definition: parsed.definition,
134
+ });
135
+ return app.id;
136
+ }
137
+
138
+ beforeAll(async () => {
139
+ for (const suffix of ["", "-wal", "-shm"]) {
140
+ try {
141
+ await unlink(`${TEST_DB_PATH}${suffix}`);
142
+ } catch {}
143
+ }
144
+ // Safe to mutate: CI runs one process per test file (`bun test --parallel=4`)
145
+ // and afterAll restores the previous value.
146
+ previousApiKey = process.env.AGENT_SWARM_API_KEY;
147
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
148
+ initDb(TEST_DB_PATH);
149
+ const user = await createUser({ name: "Parent Task User" });
150
+ userToken = (await mintToken(user.id, "apps-action-parent-task", OPERATOR_ACTOR)).plaintext;
151
+ await createAgent({ id: AGENT_ID, name: "parent-task-worker", isLead: false, status: "idle" });
152
+ await createAgent({ id: LEAD_ID, name: "parent-task-lead", isLead: true, status: "idle" });
153
+ await createAgent({
154
+ id: OTHER_AGENT_ID,
155
+ name: "parent-task-other",
156
+ isLead: false,
157
+ status: "idle",
158
+ });
159
+ server = createTestServer();
160
+ const port = await listenOnFreePort(server, "127.0.0.1");
161
+ base = `http://127.0.0.1:${port}`;
162
+ });
163
+
164
+ afterAll(async () => {
165
+ await new Promise<void>((resolve) => server.close(() => resolve()));
166
+ closeDb();
167
+ if (previousApiKey === undefined) delete process.env.AGENT_SWARM_API_KEY;
168
+ else process.env.AGENT_SWARM_API_KEY = previousApiKey;
169
+ for (const suffix of ["", "-wal", "-shm"]) {
170
+ try {
171
+ await unlink(`${TEST_DB_PATH}${suffix}`);
172
+ } catch {}
173
+ }
174
+ });
175
+
176
+ describe("$chain sentinel in the page validator", () => {
177
+ test("accepts parentTaskId: { $chain } inside an app.action step's params", async () => {
178
+ const parsed = await parseAppDefinition(definitionWith(chainedButtonPage()));
179
+ expect(parsed.success).toBe(true);
180
+ });
181
+
182
+ test("accepts a $chain sentinel nested deeper inside app.action params", async () => {
183
+ const page = chainedButtonPage();
184
+ page.elements.send.on.press[0]!.params = {
185
+ name: TASK_ACTION,
186
+ input: { context: { parentTaskId: { $chain: "leafTaskId" } } },
187
+ };
188
+ const parsed = await parseAppDefinition(definitionWith(page));
189
+ expect(parsed.success).toBe(true);
190
+ });
191
+
192
+ test("rejects a $chain sentinel used as a plain prop value", async () => {
193
+ const page = chainedButtonPage();
194
+ page.elements = {
195
+ ...page.elements,
196
+ echo: { type: "Text", props: { content: { $chain: "leafTaskId" } } },
197
+ } as typeof page.elements;
198
+ page.elements.root.children = ["send", "echo"];
199
+ const parsed = await parseAppDefinition(definitionWith(page));
200
+ expect(parsed.success).toBe(false);
201
+ if (parsed.success) return;
202
+ expect(parsed.issues.some((entry) => entry.message === CHAIN_ISSUE)).toBe(true);
203
+ });
204
+ });
205
+
206
+ describe("POST /api/apps/:id/actions/:name with parentTaskId", () => {
207
+ test("chains the created task under the given parent", async () => {
208
+ const appId = await createChainedApp();
209
+ const parent = await createTask(AGENT_ID, "Previous turn of the chain");
210
+
211
+ const started = await runAction<{ ok: boolean; taskId: string; status: string }>(appId, {
212
+ input: { prompt: "next step" },
213
+ parentTaskId: parent.id,
214
+ });
215
+ expect(started.status).toBe(200);
216
+ expect(started.body).toMatchObject({ ok: true, status: "pending" });
217
+
218
+ const created = await getTaskById(started.body.taskId);
219
+ expect(created?.parentTaskId).toBe(parent.id);
220
+ });
221
+
222
+ test("answers 404 for an unknown parent instead of storing a dangling id", async () => {
223
+ const appId = await createChainedApp();
224
+ const missing = await runAction<{ error: string }>(appId, {
225
+ input: {},
226
+ parentTaskId: crypto.randomUUID(),
227
+ });
228
+ expect(missing.status).toBe(404);
229
+ expect(missing.body.error).toBe("parent task not found");
230
+ });
231
+
232
+ test("denies an agent caller without context for the parent, as POST /api/tasks does", async () => {
233
+ const appId = await createChainedApp();
234
+ const foreign = await createTask(OTHER_AGENT_ID, "Someone else's thread");
235
+
236
+ const denied = await runAction<{ error: string }>(appId, {
237
+ input: {},
238
+ parentTaskId: foreign.id,
239
+ });
240
+ expect(denied.status).toBe(403);
241
+ expect(denied.body.error).toBe(`You don't have context for parent task ${foreign.id}.`);
242
+
243
+ // A lead keeps its `task.parent.attach` grant: the gate is about identity, not the route.
244
+ const asLead = await runAction<{ taskId: string }>(
245
+ appId,
246
+ { input: {}, parentTaskId: foreign.id },
247
+ { agentId: LEAD_ID },
248
+ );
249
+ expect(asLead.status).toBe(200);
250
+ expect((await getTaskById(asLead.body.taskId))?.parentTaskId).toBe(foreign.id);
251
+ });
252
+
253
+ test("denies a user caller without a write share on the parent's session", async () => {
254
+ const appId = await createChainedApp();
255
+ const owner = await createUser({ name: "Session Owner" });
256
+ const theirs = await createTaskExtended("Someone else's session", {
257
+ agentId: OTHER_AGENT_ID,
258
+ requestedByUserId: owner.id,
259
+ });
260
+
261
+ const denied = await runAction<{ error: string }>(
262
+ appId,
263
+ { input: {}, parentTaskId: theirs.id },
264
+ { bearer: userToken },
265
+ );
266
+ expect(denied.status).toBe(403);
267
+ expect(denied.body.error).toBe(SESSION_WRITE_DENIED);
268
+ });
269
+
270
+ test("rejects parentTaskId on a non-task action instead of dropping it", async () => {
271
+ const appId = await createChainedApp();
272
+ const parent = await createTask(AGENT_ID, "Previous turn of the chain");
273
+
274
+ const rejected = await runAction<{ error: string }>(
275
+ appId,
276
+ { input: {}, parentTaskId: parent.id },
277
+ { action: SYNC_ACTION },
278
+ );
279
+ expect(rejected.status).toBe(400);
280
+ expect(rejected.body.error).toBe("parentTaskId is only supported for task-kind actions");
281
+ });
282
+
283
+ test("rejects an empty parentTaskId", async () => {
284
+ const appId = await createChainedApp();
285
+ const rejected = await runAction<{ error: string }>(appId, { input: {}, parentTaskId: "" });
286
+ expect(rejected.status).toBe(400);
287
+ });
288
+
289
+ test("still creates a parentless task when parentTaskId is omitted", async () => {
290
+ const appId = await createChainedApp();
291
+ const started = await runAction<{ ok: boolean; taskId: string }>(appId, { input: {} });
292
+ expect(started.status).toBe(200);
293
+ expect((await getTaskById(started.body.taskId))?.parentTaskId ?? null).toBeNull();
294
+ });
295
+ });
@@ -10,7 +10,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
10
10
  import { unlink } from "node:fs/promises";
11
11
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
12
12
  import { closeDb, initDb, upsertKv } from "../be/db";
13
- import { handleCodexOAuthKeepWarm } from "../http/codex-oauth-keep-warm";
13
+ import { validateConfigValue } from "../be/swarm-config-guard";
14
+ import { handleCodexOAuthKeepWarm, keepWarmMaxAgeMs } from "../http/codex-oauth-keep-warm";
14
15
  import { handleOAuthLocks } from "../http/oauth-locks";
15
16
  import { resetFetchForTesting, setFetchForTesting } from "../providers/codex-oauth/flow.js";
16
17
  import type { CodexOAuthCredentials } from "../providers/codex-oauth/types.js";
@@ -176,6 +177,70 @@ describe("POST /api/oauth/keep-warm/codex", () => {
176
177
  expect(slots.get(1)?.access).toBe("example-at_keepwarm");
177
178
  });
178
179
 
180
+ it("refreshes a 6-day-old slot by default, before the ~6d20h point where OpenAI ended a session", async () => {
181
+ const slots = new Map<number, CodexOAuthCredentials>([
182
+ [
183
+ 0,
184
+ {
185
+ access: "at_six_days",
186
+ refresh: "rt_six_days",
187
+ expires: Date.now() + 4 * 24 * 60 * 60 * 1000, // issued ~6 days ago (10d TTL)
188
+ accountId: "acc-six",
189
+ },
190
+ ],
191
+ ]);
192
+ installMockTransport(slots);
193
+ setFetchForTesting(
194
+ async () =>
195
+ new Response(
196
+ JSON.stringify({
197
+ access_token: "example-at_six_days_new",
198
+ refresh_token: "example-rt_six_days_new",
199
+ expires_in: 864000,
200
+ }),
201
+ { status: 200, headers: { "Content-Type": "application/json" } },
202
+ ),
203
+ );
204
+
205
+ const { req, res, captured } = fakeReqRes();
206
+ await handleCodexOAuthKeepWarm(req, res, ["api", "oauth", "keep-warm", "codex"]);
207
+
208
+ const body = JSON.parse(captured.body) as { results: Array<Record<string, unknown>> };
209
+ expect(body.results[0]).toMatchObject({ slot: 0, outcome: "refreshed" });
210
+ expect(slots.get(0)?.refresh).toBe("example-rt_six_days_new");
211
+ });
212
+
213
+ it("honors CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS read at sweep time", async () => {
214
+ const saved = process.env.CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS;
215
+ process.env.CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS = "168";
216
+ try {
217
+ const slots = new Map<number, CodexOAuthCredentials>([
218
+ [
219
+ 0,
220
+ {
221
+ access: "at_six_days",
222
+ refresh: "rt_six_days",
223
+ expires: Date.now() + 4 * 24 * 60 * 60 * 1000, // issued ~6 days ago, under 168h
224
+ accountId: "acc-six",
225
+ },
226
+ ],
227
+ ]);
228
+ installMockTransport(slots);
229
+ setFetchForTesting(async () => {
230
+ throw new Error("no /oauth/token exchange should happen under a 168h threshold");
231
+ });
232
+
233
+ const { req, res, captured } = fakeReqRes();
234
+ await handleCodexOAuthKeepWarm(req, res, ["api", "oauth", "keep-warm", "codex"]);
235
+
236
+ const body = JSON.parse(captured.body) as { results: Array<Record<string, unknown>> };
237
+ expect(body.results[0]).toMatchObject({ slot: 0, outcome: "warm" });
238
+ } finally {
239
+ if (saved === undefined) delete process.env.CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS;
240
+ else process.env.CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS = saved;
241
+ }
242
+ });
243
+
179
244
  it("skips a slot with an active codex-auth-watch bench marker", async () => {
180
245
  const keySuffix = "c-old"; // last 5 of accountId (no chatgpt_user_id in this fixture's access token)
181
246
  await upsertKv({
@@ -284,6 +349,25 @@ describe("POST /api/oauth/keep-warm/codex", () => {
284
349
  }
285
350
  });
286
351
 
352
+ it("keepWarmMaxAgeMs defaults to 120h and ignores invalid or out-of-range values", () => {
353
+ const hour = 60 * 60 * 1000;
354
+ expect(keepWarmMaxAgeMs({})).toBe(120 * hour);
355
+ expect(keepWarmMaxAgeMs({ CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS: " 72 " })).toBe(72 * hour);
356
+ expect(keepWarmMaxAgeMs({ CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS: "216" })).toBe(216 * hour);
357
+ for (const bad of ["0", "217", "1.5", "-3", "abc", ""]) {
358
+ expect(keepWarmMaxAgeMs({ CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS: bad })).toBe(120 * hour);
359
+ }
360
+ });
361
+
362
+ it("the config API rejects the values keepWarmMaxAgeMs would fall back from", () => {
363
+ const key = "CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS";
364
+ expect(validateConfigValue(key, "1")).toBeNull();
365
+ expect(validateConfigValue(key, "216")).toBeNull();
366
+ expect(validateConfigValue(key, "0")).toContain("between 1 and 216");
367
+ expect(validateConfigValue(key, "217")).toContain("between 1 and 216");
368
+ expect(validateConfigValue(key, "1.5")).toContain("integer");
369
+ });
370
+
287
371
  it("does not match unrelated paths", async () => {
288
372
  const { req, res } = fakeReqRes();
289
373
  const handled = await handleCodexOAuthKeepWarm(req, res, [
@@ -1,6 +1,7 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { createServer, type Server } from "node:http";
3
3
  import { closeDb, createAgent, initDb, upsertSwarmConfig } from "../be/db";
4
+ import { mintAgentToken, revokeAgentTokens } from "../be/users";
4
5
  import { handleConfig } from "../http/config";
5
6
  import { handleCore } from "../http/core";
6
7
  import { handleSessions } from "../http/sessions";
@@ -116,6 +117,19 @@ describe("config HTTP session-token authorization", () => {
116
117
  expect((await response.json()).configs[0].value).toBe("global");
117
118
  });
118
119
 
120
+ test("agent token resolved config without agentId reads its own agent scope", async () => {
121
+ const token = await mintAgentToken(workerId);
122
+ try {
123
+ const response = await fetch(`${baseUrl}/api/config/resolved?includeSecrets=true`, {
124
+ headers: headers(token.plaintext),
125
+ });
126
+ expect(response.status).toBe(200);
127
+ expect(await response.text()).toContain(SECRET);
128
+ } finally {
129
+ await revokeAgentTokens(workerId);
130
+ }
131
+ });
132
+
119
133
  for (const method of ["PUT", "DELETE"] as const) {
120
134
  test(`${method} cannot escalate a non-lead session by changing only X-Agent-ID`, async () => {
121
135
  const token = await mint(workerId);
@@ -103,8 +103,8 @@ describe("docs API index footprint", () => {
103
103
  expect(spec.paths["/api/agents/{id}/runtime"]?.get).toBeDefined();
104
104
 
105
105
  const generatedKeys = await generatedOperationKeys();
106
- expect(operationKeys).toHaveLength(437);
107
- expect(generatedKeys).toHaveLength(437);
106
+ expect(operationKeys).toHaveLength(440);
107
+ expect(generatedKeys).toHaveLength(440);
108
108
  expect(new Set(generatedKeys)).toEqual(new Set(operationKeys));
109
109
  });
110
110
 
@@ -30,6 +30,7 @@ import {
30
30
  } from "../providers/grok-adapter";
31
31
  import type { ProviderEvent, ProviderSessionConfig } from "../providers/types";
32
32
  import { getModelAwareCredentialVars } from "../utils/credentials";
33
+ import { ACP_SDK_PATH, serveFakeAcpAgent } from "./acp-fake-agent";
33
34
 
34
35
  /**
35
36
  * Recorded from a live `grok agent stdio` session (Grok CLI 1.0.46,
@@ -39,7 +40,6 @@ import { getModelAwareCredentialVars } from "../utils/credentials";
39
40
  */
40
41
  const FIXTURE = join(import.meta.dir, "fixtures", "grok", "acp-updates-recorded.jsonl");
41
42
  const PROMPT_META = join(import.meta.dir, "fixtures", "grok", "prompt-response-meta.json");
42
- const SDK_PATH = join(process.cwd(), "node_modules/@agentclientprotocol/sdk/dist/acp.js");
43
43
 
44
44
  const tmpDirs: string[] = [];
45
45
 
@@ -129,7 +129,7 @@ function writeFakeGrok(
129
129
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
130
130
  import { join } from "node:path";
131
131
  import { Readable, Writable } from "node:stream";
132
- import { AgentSideConnection, PROTOCOL_VERSION, RequestError, ndJsonStream } from "${SDK_PATH}";
132
+ import { PROTOCOL_VERSION, RequestError, agent, methods, ndJsonStream } from "${ACP_SDK_PATH}";
133
133
  const home = process.env.GROK_HOME ?? "";
134
134
  const read = (name) => (existsSync(join(home, name)) ? readFileSync(join(home, name), "utf8") : null);
135
135
  const capture = {
@@ -160,7 +160,7 @@ class FakeGrok {
160
160
  }
161
161
  async cancel() { this.cancelled?.(); }
162
162
  }
163
- new AgentSideConnection((c) => new FakeGrok(c), ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)));
163
+ ${serveFakeAcpAgent("FakeGrok")}
164
164
  `,
165
165
  );
166
166
  const binPath = join(dir, "grok");
@@ -261,6 +261,29 @@ describe("heartbeat-jev-gate with a provider", () => {
261
261
  });
262
262
  });
263
263
 
264
+ test("only a bare MM-DD counts as a due tracked item", async () => {
265
+ const mocked = Date.now;
266
+ Date.now = () => Date.parse("2026-10-20T09:30:00.000Z");
267
+ try {
268
+ const { ctx } = fakeCtx({
269
+ configs: { TYPESAFE_API_KEY: "x" },
270
+ heartbeatMd: [
271
+ "- **2026-10-20 ~10:45Z (heartbeat 2026-10-20):** re-verified, nothing pending",
272
+ "- Watch: 10-20s retry window closed, nothing pending",
273
+ "- Watch: session 4a12-10-20-9e2f finished cleanly, nothing pending",
274
+ "- Watch: release on 10-20 — lift after ship",
275
+ ].join("\n"),
276
+ jev: jevSays(0.9),
277
+ });
278
+ const verdict = await run(ctx, { dryRun: true });
279
+ expect(verdict.state.tracked_items_mentioning_today_or_past_delete_date).toEqual([
280
+ "- Watch: release on 10-20 — lift after ship",
281
+ ]);
282
+ } finally {
283
+ Date.now = mocked;
284
+ }
285
+ });
286
+
264
287
  for (const ordersFirst of [true, false]) {
265
288
  test(`previous failures are not new with standing orders ${ordersFirst ? "before" : "after"} status`, async () => {
266
289
  const failures =
@@ -0,0 +1,67 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { expect, test } from "bun:test";
3
+
4
+ test("migration 232 preserves every token kind and adds agent-only tokens", async () => {
5
+ const db = new Database(":memory:");
6
+ try {
7
+ db.exec(await Bun.file("src/be/migrations/149_session_tokens.sql").text());
8
+ db.exec(await Bun.file("src/be/migrations/207_runtime_session_tokens.sql").text());
9
+ const seed = db.query(
10
+ `INSERT INTO session_tokens
11
+ (id, tokenHash, tokenPreview, agentId, taskId, runtimeId, parentRuntimeId, controllerId,
12
+ expiresAt, revokedAt, createdAt, lastUsedAt)
13
+ VALUES (?, ?, 'view', 'agent', ?, ?, ?, ?, '2030-01-01', NULL, '2025-01-01', '2025-12-01')`,
14
+ );
15
+ seed.run("acp", "acp", "task", null, null, null);
16
+ seed.run("runtime", "runtime", null, "runtime", null, null);
17
+ seed.run("child", "child", "task", null, "runtime", null);
18
+ seed.run("controller", "controller", null, null, null, "ctrl");
19
+ const before = db.query("SELECT * FROM session_tokens ORDER BY id").all();
20
+
21
+ db.exec(await Bun.file("src/be/migrations/232_agent_session_tokens.sql").text());
22
+
23
+ // prepare, not query: query() caches the statement and its pre-migration column list.
24
+ expect(db.prepare("SELECT * FROM session_tokens ORDER BY id").all()).toEqual(
25
+ before.map((row) => ({ ...(row as object), parentTokenId: null })),
26
+ );
27
+ const insert = db.query(
28
+ `INSERT INTO session_tokens
29
+ (id, tokenHash, tokenPreview, agentId, taskId, runtimeId, parentRuntimeId, controllerId,
30
+ parentTokenId, expiresAt)
31
+ VALUES (?, ?, 'view', 'agent', ?, ?, ?, ?, ?, '2030-01-01')`,
32
+ );
33
+ insert.run("worker", "worker", null, null, null, null, null);
34
+ insert.run("worker-child", "worker-child", "task", null, null, null, "worker");
35
+ expect(() => insert.run("mixed", "mixed", "task", "runtime", null, null, null)).toThrow();
36
+ expect(() =>
37
+ insert.run("two-parents", "two-parents", "task", null, "rt", null, "worker"),
38
+ ).toThrow();
39
+ expect(() =>
40
+ insert.run("runtime-parent-token", "runtime-parent-token", null, "rt", null, null, "worker"),
41
+ ).toThrow();
42
+ expect(() =>
43
+ insert.run("controller-parent", "controller-parent", null, null, null, "ctrl", "worker"),
44
+ ).toThrow();
45
+ expect(() =>
46
+ insert.run("agent-parent-runtime", "agent-parent-runtime", null, null, "rt", null, null),
47
+ ).toThrow();
48
+ expect(() =>
49
+ insert.run("agent-with-parent", "agent-with-parent", null, null, null, null, "worker"),
50
+ ).toThrow();
51
+ const indexes = db
52
+ .query<{ name: string }, []>("PRAGMA index_list(session_tokens)")
53
+ .all()
54
+ .map((row) => row.name);
55
+ for (const name of [
56
+ "idx_session_tokens_agentId",
57
+ "idx_session_tokens_runtimeId",
58
+ "idx_session_tokens_parentRuntimeId",
59
+ "idx_session_tokens_parentTokenId",
60
+ ]) {
61
+ expect(indexes).toContain(name);
62
+ }
63
+ expect(db.query("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" });
64
+ } finally {
65
+ db.close();
66
+ }
67
+ });
@@ -105,6 +105,7 @@ const OPERATOR_OR_USER_VERBS: PermissionVerb[] = [
105
105
  "approval.respond",
106
106
  "runtime_controller.write",
107
107
  "runtime_pool.write",
108
+ "agent.token.manage",
108
109
  ];
109
110
 
110
111
  const LEAD_OR_OPERATOR_VERBS: PermissionVerb[] = ["models.catalog.write"];
@@ -9,6 +9,7 @@ import { handleSessions } from "../http/sessions";
9
9
  import { getPathSegments, parseQueryParams } from "../http/utils";
10
10
  import { ACPAdapter } from "../providers/acp-adapter";
11
11
  import { setRequestAuth } from "../utils/request-auth-context";
12
+ import { ACP_SDK_PATH, serveFakeAcpAgent } from "./acp-fake-agent";
12
13
  import { listenOnFreePort } from "./test-net";
13
14
  import { CHILD_PROCESS_TEST_BUDGET_MS } from "./test-proc";
14
15
 
@@ -69,12 +70,11 @@ for (const setupFails of [false, true]) {
69
70
  const api = await startApi(resolveRevoke);
70
71
  const script = join(tmpdir(), `runtime-child-${crypto.randomUUID()}.ts`);
71
72
  const capture = `${script}.auth`;
72
- const sdk = join(process.cwd(), "node_modules/@agentclientprotocol/sdk/dist/acp.js");
73
73
  await Bun.write(
74
74
  script,
75
75
  `
76
76
  import { Readable, Writable } from "node:stream";
77
- import { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } from ${JSON.stringify(sdk)};
77
+ import { PROTOCOL_VERSION, agent, methods, ndJsonStream } from ${JSON.stringify(ACP_SDK_PATH)};
78
78
  class Agent {
79
79
  async initialize() {
80
80
  return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: false } };
@@ -89,7 +89,7 @@ class Agent {
89
89
  async prompt() { return { stopReason: "end_turn" }; }
90
90
  async cancel() {}
91
91
  }
92
- new AgentSideConnection(() => new Agent(), ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)));
92
+ ${serveFakeAcpAgent("Agent")}
93
93
  `,
94
94
  );
95
95
  try {
@@ -636,6 +636,8 @@ describe("script connections", () => {
636
636
  markMigrationApplied(database, "227_inbox_item_state_last_read_at.sql");
637
637
  // 231 indexes agent_tasks columns that this fixture's id-only agent_tasks lacks.
638
638
  markMigrationApplied(database, "231_slack_tasks_last_updated_index.sql");
639
+ // 232 rebuilds session_tokens with the 207 columns, which this fixture skipped.
640
+ markMigrationApplied(database, "232_agent_session_tokens.sql");
639
641
 
640
642
  const id = crypto.randomUUID();
641
643
  const now = new Date().toISOString();
@@ -25,6 +25,7 @@ import "../http/all-routes";
25
25
  import { handleActiveSessions } from "../http/active-sessions";
26
26
  import { handleApiKeys } from "../http/api-keys";
27
27
  import { handleApprovalRequests } from "../http/approval-requests";
28
+ import { handleApps } from "../http/apps";
28
29
  import { handleContext } from "../http/context";
29
30
  import { handleCore } from "../http/core";
30
31
  import { handleEvents } from "../http/events";
@@ -76,6 +77,7 @@ const HANDLERS: Handler[] = [
76
77
  handleFs,
77
78
  handleActiveSessions,
78
79
  handleSessions,
80
+ handleApps,
79
81
  (req, res, segments, query) => handleApprovalRequests(req, res, segments, query),
80
82
  (req, res, segments, query) => handleApiKeys(req, res, segments, query),
81
83
  (req, res, segments, query) => handleScriptRuns(req, res, segments, query),