@desplega.ai/agent-swarm 1.79.0 → 1.79.2

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 (46) hide show
  1. package/README.md +2 -0
  2. package/openapi.json +559 -1
  3. package/package.json +4 -4
  4. package/plugin/skills/kv-storage/SKILL.md +168 -0
  5. package/plugin/skills/pages/SKILL.md +149 -0
  6. package/src/artifact-sdk/browser-sdk.ts +292 -0
  7. package/src/be/db.ts +309 -0
  8. package/src/be/migrations/061_kv_store.sql +34 -0
  9. package/src/be/migrations/062_pages_view_count.sql +9 -0
  10. package/src/commands/provider-credentials.ts +1 -1
  11. package/src/http/index.ts +2 -0
  12. package/src/http/kv.ts +658 -0
  13. package/src/http/page-proxy.ts +5 -0
  14. package/src/http/pages-public.ts +50 -6
  15. package/src/http/status.ts +1 -1
  16. package/src/providers/claude-adapter.ts +138 -7
  17. package/src/providers/pi-mono-adapter.ts +3 -3
  18. package/src/providers/pi-mono-extension.ts +1 -1
  19. package/src/server.ts +20 -1
  20. package/src/tasks/context-key.ts +28 -0
  21. package/src/telemetry.ts +65 -1
  22. package/src/tests/claude-adapter-binary.test.ts +628 -0
  23. package/src/tests/context-key.test.ts +17 -0
  24. package/src/tests/kv-http.test.ts +331 -0
  25. package/src/tests/kv-namespace-resolution.test.ts +172 -0
  26. package/src/tests/kv-page-proxy.test.ts +212 -0
  27. package/src/tests/kv-storage.test.ts +227 -0
  28. package/src/tests/kv-tool.test.ts +217 -0
  29. package/src/tests/page-proxy.test.ts +5 -1
  30. package/src/tests/page-session.test.ts +10 -5
  31. package/src/tests/pages-authed-mode.test.ts +5 -1
  32. package/src/tests/pages-public-html.test.ts +10 -1
  33. package/src/tests/pages-view-count.test.ts +220 -0
  34. package/src/tests/swarm-diff.test.ts +303 -0
  35. package/src/tests/telemetry-init.test.ts +149 -0
  36. package/src/tools/kv/index.ts +5 -0
  37. package/src/tools/kv/kv-delete.ts +89 -0
  38. package/src/tools/kv/kv-get.ts +64 -0
  39. package/src/tools/kv/kv-incr.ts +116 -0
  40. package/src/tools/kv/kv-list.ts +81 -0
  41. package/src/tools/kv/kv-set.ts +194 -0
  42. package/src/tools/kv/resolve-namespace.ts +58 -0
  43. package/src/tools/tool-config.ts +7 -0
  44. package/src/types.ts +53 -0
  45. package/src/utils/internal-ai/complete-structured.ts +7 -10
  46. package/src/utils/internal-ai/credentials.ts +3 -3
@@ -0,0 +1,331 @@
1
+ // REST surface for /api/kv/*. Spins up a real HTTP server with the same
2
+ // auth → handleKv pipeline as `src/http/index.ts` so we exercise the bearer
3
+ // gate, header parsing, content-length cap, namespace resolution and auth.
4
+
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
6
+ import { unlink } from "node:fs/promises";
7
+ import {
8
+ createServer as createHttpServer,
9
+ type IncomingMessage,
10
+ type Server,
11
+ type ServerResponse,
12
+ } from "node:http";
13
+ import { closeDb, createAgent, createTaskExtended, getDb, initDb } from "../be/db";
14
+ import { handleCore } from "../http/core";
15
+ import { handleKv } from "../http/kv";
16
+ import { getPathSegments, parseQueryParams } from "../http/utils";
17
+ import { slackContextKey as buildSlackContextKey } from "../tasks/context-key";
18
+
19
+ const TEST_DB_PATH = "./test-kv-http.sqlite";
20
+ const API_KEY = "test-kv-key";
21
+
22
+ async function removeDbFiles(path: string): Promise<void> {
23
+ for (const suffix of ["", "-wal", "-shm"]) {
24
+ try {
25
+ await unlink(path + suffix);
26
+ } catch (error) {
27
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
28
+ }
29
+ }
30
+ }
31
+
32
+ async function listen(server: Server): Promise<number> {
33
+ await new Promise<void>((resolve) => server.listen(0, resolve));
34
+ const addr = server.address();
35
+ if (!addr || typeof addr === "string") throw new Error("no port");
36
+ return addr.port;
37
+ }
38
+
39
+ function createTestServer(apiKey: string): Server {
40
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
41
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
42
+ const handled = await handleCore(req, res, myAgentId, apiKey);
43
+ if (handled) return;
44
+ const pathSegments = getPathSegments(req.url || "");
45
+ const queryParams = parseQueryParams(req.url || "");
46
+ const ok = await handleKv(req, res, pathSegments, queryParams);
47
+ if (!ok) {
48
+ res.writeHead(404);
49
+ res.end("Not Found");
50
+ }
51
+ });
52
+ }
53
+
54
+ let server: Server;
55
+ let port: number;
56
+ let agentId: string;
57
+ let otherAgentId: string;
58
+ let leadAgentId: string;
59
+ let slackTaskId: string;
60
+ let slackContextKey: string;
61
+
62
+ beforeAll(async () => {
63
+ await removeDbFiles(TEST_DB_PATH);
64
+ initDb(TEST_DB_PATH);
65
+ server = createTestServer(API_KEY);
66
+ port = await listen(server);
67
+
68
+ const a = createAgent({ name: "kv-test-a", isLead: false, status: "idle" });
69
+ const b = createAgent({ name: "kv-test-b", isLead: false, status: "idle" });
70
+ const lead = createAgent({ name: "kv-test-lead", isLead: true, status: "idle" });
71
+ agentId = a.id;
72
+ otherAgentId = b.id;
73
+ leadAgentId = lead.id;
74
+
75
+ slackContextKey = buildSlackContextKey({
76
+ channelId: "CKVTEST",
77
+ threadTs: "1700000000.123456",
78
+ });
79
+ const slackTask = createTaskExtended("kv test task", {
80
+ agentId,
81
+ source: "mcp",
82
+ slackChannelId: "CKVTEST",
83
+ slackThreadTs: "1700000000.123456",
84
+ slackUserId: "UKV",
85
+ contextKey: slackContextKey,
86
+ });
87
+ slackTaskId = slackTask.id;
88
+ });
89
+
90
+ afterAll(async () => {
91
+ await new Promise<void>((resolve) => server.close(() => resolve()));
92
+ closeDb();
93
+ await removeDbFiles(TEST_DB_PATH);
94
+ });
95
+
96
+ beforeEach(() => {
97
+ getDb().run("DELETE FROM kv_entries");
98
+ });
99
+
100
+ function url(path: string): string {
101
+ return `http://localhost:${port}${path}`;
102
+ }
103
+
104
+ function authedFetch(
105
+ path: string,
106
+ init: RequestInit & { agentId?: string; sourceTaskId?: string; pageId?: string } = {},
107
+ ): Promise<Response> {
108
+ const headers: Record<string, string> = {
109
+ Authorization: `Bearer ${API_KEY}`,
110
+ "Content-Type": "application/json",
111
+ ...((init.headers as Record<string, string>) ?? {}),
112
+ };
113
+ if (init.agentId !== undefined) headers["X-Agent-ID"] = init.agentId;
114
+ if (init.sourceTaskId !== undefined) headers["X-Source-Task-Id"] = init.sourceTaskId;
115
+ if (init.pageId !== undefined) headers["X-Page-Id"] = init.pageId;
116
+ return fetch(url(path), { ...init, headers });
117
+ }
118
+
119
+ describe("/api/kv REST — auth", () => {
120
+ test("401 without Authorization header", async () => {
121
+ const res = await fetch(url("/api/kv/foo"), { headers: { "X-Agent-ID": agentId } });
122
+ expect(res.status).toBe(401);
123
+ });
124
+
125
+ test("400 when no resolvable namespace header is provided", async () => {
126
+ const res = await authedFetch("/api/kv/foo");
127
+ expect(res.status).toBe(400);
128
+ });
129
+ });
130
+
131
+ describe("/api/kv REST — header-resolved namespace", () => {
132
+ test("PUT + GET round-trip on agent namespace", async () => {
133
+ const put = await authedFetch("/api/kv/note", {
134
+ method: "PUT",
135
+ body: JSON.stringify({ value: { hello: "world" } }),
136
+ agentId,
137
+ });
138
+ expect(put.status).toBe(200);
139
+ const stored = await put.json();
140
+ expect(stored.namespace).toBe(`task:agent:${agentId}`);
141
+ expect(stored.value).toEqual({ hello: "world" });
142
+
143
+ const get = await authedFetch("/api/kv/note", { agentId });
144
+ expect(get.status).toBe(200);
145
+ const got = await get.json();
146
+ expect(got.value).toEqual({ hello: "world" });
147
+ });
148
+
149
+ test("X-Source-Task-Id wins over X-Agent-ID — namespace is the task's contextKey", async () => {
150
+ const put = await authedFetch("/api/kv/cursor", {
151
+ method: "PUT",
152
+ body: JSON.stringify({ value: 42, valueType: "integer" }),
153
+ agentId,
154
+ sourceTaskId: slackTaskId,
155
+ });
156
+ expect(put.status).toBe(200);
157
+ const stored = await put.json();
158
+ expect(stored.namespace).toBe(slackContextKey);
159
+
160
+ // Reading with the same headers finds the entry...
161
+ const get1 = await authedFetch("/api/kv/cursor", { agentId, sourceTaskId: slackTaskId });
162
+ expect(get1.status).toBe(200);
163
+
164
+ // ...but reading with only the agent header doesn't (different ns).
165
+ const get2 = await authedFetch("/api/kv/cursor", { agentId });
166
+ expect(get2.status).toBe(404);
167
+ });
168
+
169
+ test("DELETE returns 204 then 404", async () => {
170
+ await authedFetch("/api/kv/gone", {
171
+ method: "PUT",
172
+ body: JSON.stringify({ value: "soon", valueType: "string" }),
173
+ agentId,
174
+ });
175
+ const del1 = await authedFetch("/api/kv/gone", { method: "DELETE", agentId });
176
+ expect(del1.status).toBe(204);
177
+ const del2 = await authedFetch("/api/kv/gone", { method: "DELETE", agentId });
178
+ expect(del2.status).toBe(404);
179
+ });
180
+
181
+ test("POST /incr creates and increments", async () => {
182
+ const r1 = await authedFetch("/api/kv/votes/incr", {
183
+ method: "POST",
184
+ body: JSON.stringify({ by: 3 }),
185
+ agentId,
186
+ });
187
+ expect(r1.status).toBe(200);
188
+ expect((await r1.json()).value).toBe(3);
189
+
190
+ const r2 = await authedFetch("/api/kv/votes/incr", {
191
+ method: "POST",
192
+ body: JSON.stringify({}),
193
+ agentId,
194
+ });
195
+ expect((await r2.json()).value).toBe(4);
196
+ });
197
+
198
+ test("POST /incr returns 409 on valueType collision", async () => {
199
+ await authedFetch("/api/kv/obj", {
200
+ method: "PUT",
201
+ body: JSON.stringify({ value: { a: 1 } }),
202
+ agentId,
203
+ });
204
+ const res = await authedFetch("/api/kv/obj/incr", {
205
+ method: "POST",
206
+ body: JSON.stringify({}),
207
+ agentId,
208
+ });
209
+ expect(res.status).toBe(409);
210
+ });
211
+
212
+ test("GET /api/kv lists with prefix + total", async () => {
213
+ await authedFetch("/api/kv/a-1", {
214
+ method: "PUT",
215
+ body: JSON.stringify({ value: 1, valueType: "integer" }),
216
+ agentId,
217
+ });
218
+ await authedFetch("/api/kv/a-2", {
219
+ method: "PUT",
220
+ body: JSON.stringify({ value: 2, valueType: "integer" }),
221
+ agentId,
222
+ });
223
+ await authedFetch("/api/kv/b-1", {
224
+ method: "PUT",
225
+ body: JSON.stringify({ value: 3, valueType: "integer" }),
226
+ agentId,
227
+ });
228
+
229
+ const r = await authedFetch("/api/kv?prefix=a-&limit=10", { agentId });
230
+ expect(r.status).toBe(200);
231
+ const body = await r.json();
232
+ expect(body.entries.map((e: { key: string }) => e.key)).toEqual(["a-1", "a-2"]);
233
+ expect(body.total).toBe(2);
234
+ expect(body.namespace).toBe(`task:agent:${agentId}`);
235
+ });
236
+ });
237
+
238
+ describe("/api/kv REST — explicit namespace shape", () => {
239
+ test("PUT then GET under /_/{namespace}/{key}", async () => {
240
+ const ns = "swarm:test:explicit";
241
+ const put = await authedFetch(`/api/kv/_/${encodeURIComponent(ns)}/foo`, {
242
+ method: "PUT",
243
+ body: JSON.stringify({ value: "hi", valueType: "string" }),
244
+ agentId,
245
+ });
246
+ expect(put.status).toBe(200);
247
+ const get = await authedFetch(`/api/kv/_/${encodeURIComponent(ns)}/foo`, {
248
+ agentId,
249
+ });
250
+ expect(get.status).toBe(200);
251
+ expect((await get.json()).value).toBe("hi");
252
+ });
253
+
254
+ test("list with explicit namespace", async () => {
255
+ const ns = "swarm:test:explicit-list";
256
+ await authedFetch(`/api/kv/_/${encodeURIComponent(ns)}/k`, {
257
+ method: "PUT",
258
+ body: JSON.stringify({ value: 1, valueType: "integer" }),
259
+ agentId,
260
+ });
261
+ const r = await authedFetch(`/api/kv/_/${encodeURIComponent(ns)}`, { agentId });
262
+ expect(r.status).toBe(200);
263
+ const body = await r.json();
264
+ expect(body.namespace).toBe(ns);
265
+ expect(body.total).toBe(1);
266
+ });
267
+ });
268
+
269
+ describe("/api/kv REST — auth on writes", () => {
270
+ test("403 when writing to another agent's namespace", async () => {
271
+ const ns = `task:agent:${otherAgentId}`;
272
+ const res = await authedFetch(`/api/kv/_/${encodeURIComponent(ns)}/k`, {
273
+ method: "PUT",
274
+ body: JSON.stringify({ value: 1 }),
275
+ agentId,
276
+ });
277
+ expect(res.status).toBe(403);
278
+ });
279
+
280
+ test("lead can write to any agent's namespace", async () => {
281
+ const ns = `task:agent:${otherAgentId}`;
282
+ const res = await authedFetch(`/api/kv/_/${encodeURIComponent(ns)}/k`, {
283
+ method: "PUT",
284
+ body: JSON.stringify({ value: 1 }),
285
+ agentId: leadAgentId,
286
+ });
287
+ expect(res.status).toBe(200);
288
+ });
289
+
290
+ test("any agent can READ another agent's namespace", async () => {
291
+ const ns = `task:agent:${otherAgentId}`;
292
+ // Seed via lead
293
+ await authedFetch(`/api/kv/_/${encodeURIComponent(ns)}/k`, {
294
+ method: "PUT",
295
+ body: JSON.stringify({ value: "hi", valueType: "string" }),
296
+ agentId: leadAgentId,
297
+ });
298
+ const r = await authedFetch(`/api/kv/_/${encodeURIComponent(ns)}/k`, { agentId });
299
+ expect(r.status).toBe(200);
300
+ expect((await r.json()).value).toBe("hi");
301
+ });
302
+
303
+ test("403 when writing to task:page:* without an X-Page-Id header", async () => {
304
+ const ns = "task:page:abc";
305
+ const res = await authedFetch(`/api/kv/_/${encodeURIComponent(ns)}/k`, {
306
+ method: "PUT",
307
+ body: JSON.stringify({ value: 1 }),
308
+ agentId,
309
+ });
310
+ expect(res.status).toBe(403);
311
+ });
312
+ });
313
+
314
+ describe("/api/kv REST — body cap", () => {
315
+ test("413 when Content-Length > 2 MiB", async () => {
316
+ // Build a body that exceeds 2 MiB and signal it via Content-Length.
317
+ const big = "x".repeat(2 * 1024 * 1024 + 1);
318
+ const body = JSON.stringify({ value: big, valueType: "string" });
319
+ const res = await fetch(url("/api/kv/big"), {
320
+ method: "PUT",
321
+ headers: {
322
+ Authorization: `Bearer ${API_KEY}`,
323
+ "Content-Type": "application/json",
324
+ "X-Agent-ID": agentId,
325
+ "Content-Length": String(body.length),
326
+ },
327
+ body,
328
+ });
329
+ expect(res.status).toBe(413);
330
+ });
331
+ });
@@ -0,0 +1,172 @@
1
+ // Header-precedence matrix for KV namespace resolution.
2
+ // Exercises the same paths as kv-http but focuses on which header wins.
3
+
4
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import { unlink } from "node:fs/promises";
6
+ import {
7
+ createServer as createHttpServer,
8
+ type IncomingMessage,
9
+ type Server,
10
+ type ServerResponse,
11
+ } from "node:http";
12
+ import { closeDb, createAgent, createTaskExtended, getDb, initDb } from "../be/db";
13
+ import { handleCore } from "../http/core";
14
+ import { handleKv } from "../http/kv";
15
+ import { getPathSegments, parseQueryParams } from "../http/utils";
16
+ import { githubContextKey, linearContextKey, slackContextKey } from "../tasks/context-key";
17
+
18
+ const TEST_DB_PATH = "./test-kv-ns-resolution.sqlite";
19
+ const API_KEY = "test-kv-ns-key";
20
+
21
+ async function removeDbFiles(path: string): Promise<void> {
22
+ for (const suffix of ["", "-wal", "-shm"]) {
23
+ try {
24
+ await unlink(path + suffix);
25
+ } catch (error) {
26
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
27
+ }
28
+ }
29
+ }
30
+
31
+ async function listen(server: Server): Promise<number> {
32
+ await new Promise<void>((resolve) => server.listen(0, resolve));
33
+ const addr = server.address();
34
+ if (!addr || typeof addr === "string") throw new Error("no port");
35
+ return addr.port;
36
+ }
37
+
38
+ let server: Server;
39
+ let port: number;
40
+ let agentId: string;
41
+
42
+ beforeAll(async () => {
43
+ await removeDbFiles(TEST_DB_PATH);
44
+ initDb(TEST_DB_PATH);
45
+ server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
46
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
47
+ if (await handleCore(req, res, myAgentId, API_KEY)) return;
48
+ const pathSegments = getPathSegments(req.url || "");
49
+ const queryParams = parseQueryParams(req.url || "");
50
+ const ok = await handleKv(req, res, pathSegments, queryParams);
51
+ if (!ok) {
52
+ res.writeHead(404);
53
+ res.end("Not Found");
54
+ }
55
+ });
56
+ port = await listen(server);
57
+ const a = createAgent({ name: "kv-ns-test", isLead: false, status: "idle" });
58
+ agentId = a.id;
59
+ });
60
+
61
+ afterAll(async () => {
62
+ await new Promise<void>((resolve) => server.close(() => resolve()));
63
+ closeDb();
64
+ await removeDbFiles(TEST_DB_PATH);
65
+ });
66
+
67
+ beforeEach(() => {
68
+ getDb().run("DELETE FROM kv_entries");
69
+ });
70
+
71
+ function url(path: string): string {
72
+ return `http://localhost:${port}${path}`;
73
+ }
74
+
75
+ function authedPut(
76
+ path: string,
77
+ body: unknown,
78
+ headers: Record<string, string>,
79
+ ): Promise<Response> {
80
+ return fetch(url(path), {
81
+ method: "PUT",
82
+ headers: {
83
+ Authorization: `Bearer ${API_KEY}`,
84
+ "Content-Type": "application/json",
85
+ ...headers,
86
+ },
87
+ body: JSON.stringify(body),
88
+ });
89
+ }
90
+
91
+ describe("kv namespace resolution — header precedence", () => {
92
+ test("X-Source-Task-Id with Slack contextKey wins", async () => {
93
+ const ns = slackContextKey({ channelId: "CABC", threadTs: "1700000000.000001" });
94
+ const task = createTaskExtended("slack-test", {
95
+ agentId,
96
+ source: "mcp",
97
+ slackChannelId: "CABC",
98
+ slackThreadTs: "1700000000.000001",
99
+ slackUserId: "UABC",
100
+ contextKey: ns,
101
+ });
102
+ const res = await authedPut(
103
+ "/api/kv/note",
104
+ { value: "slack-val", valueType: "string" },
105
+ { "X-Agent-ID": agentId, "X-Source-Task-Id": task.id },
106
+ );
107
+ expect(res.status).toBe(200);
108
+ expect((await res.json()).namespace).toBe(ns);
109
+ });
110
+
111
+ test("X-Source-Task-Id with Linear contextKey wins", async () => {
112
+ const ns = linearContextKey({ issueIdentifier: "DES-99" });
113
+ const task = createTaskExtended("linear-test", {
114
+ agentId,
115
+ source: "mcp",
116
+ contextKey: ns,
117
+ });
118
+ const res = await authedPut(
119
+ "/api/kv/note",
120
+ { value: "linear-val", valueType: "string" },
121
+ { "X-Agent-ID": agentId, "X-Source-Task-Id": task.id },
122
+ );
123
+ expect(res.status).toBe(200);
124
+ expect((await res.json()).namespace).toBe(ns);
125
+ });
126
+
127
+ test("X-Source-Task-Id with GitHub contextKey wins", async () => {
128
+ const ns = githubContextKey({
129
+ owner: "desplega-ai",
130
+ repo: "agent-swarm",
131
+ kind: "pr",
132
+ number: 999,
133
+ });
134
+ const task = createTaskExtended("gh-test", {
135
+ agentId,
136
+ source: "mcp",
137
+ contextKey: ns,
138
+ });
139
+ const res = await authedPut(
140
+ "/api/kv/note",
141
+ { value: "gh-val", valueType: "string" },
142
+ { "X-Agent-ID": agentId, "X-Source-Task-Id": task.id },
143
+ );
144
+ expect(res.status).toBe(200);
145
+ expect((await res.json()).namespace).toBe(ns);
146
+ });
147
+
148
+ test("falls back to task:agent:<id> when X-Source-Task-Id is absent", async () => {
149
+ const res = await authedPut(
150
+ "/api/kv/note",
151
+ { value: "agent-val", valueType: "string" },
152
+ { "X-Agent-ID": agentId },
153
+ );
154
+ expect(res.status).toBe(200);
155
+ expect((await res.json()).namespace).toBe(`task:agent:${agentId}`);
156
+ });
157
+
158
+ test("falls back to agent ns when X-Source-Task-Id points at an unknown task", async () => {
159
+ const res = await authedPut(
160
+ "/api/kv/note",
161
+ { value: "fb", valueType: "string" },
162
+ { "X-Agent-ID": agentId, "X-Source-Task-Id": "00000000-0000-4000-8000-000000000000" },
163
+ );
164
+ expect(res.status).toBe(200);
165
+ expect((await res.json()).namespace).toBe(`task:agent:${agentId}`);
166
+ });
167
+
168
+ test("400 when no usable header is provided", async () => {
169
+ const res = await authedPut("/api/kv/note", { value: "x", valueType: "string" }, {});
170
+ expect(res.status).toBe(400);
171
+ });
172
+ });