@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,212 @@
1
+ /**
2
+ * Page proxy + KV: spawn the real http server, register a page-owning agent,
3
+ * create + launch a page to mint a cookie, then exercise `/@swarm/api/kv/*`.
4
+ *
5
+ * Verifies:
6
+ * 1. KV via the cookie writes under `task:page:<id>` automatically.
7
+ * 2. Even when the SDK constructs a path with a different explicit
8
+ * namespace (`/@swarm/api/kv/_/<other-ns>/k`), the proxy's injected
9
+ * `X-Page-Id` is treated as the highest-priority namespace source —
10
+ * the request lands in `task:page:<id>` regardless.
11
+ * 3. Reading from the agent's `task:agent:*` namespace via the cookie is
12
+ * ALSO forced to the page namespace (the page can't see the agent's
13
+ * scratchpad).
14
+ */
15
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
16
+ import { randomUUID } from "node:crypto";
17
+ import { unlink } from "node:fs/promises";
18
+ import type { Subprocess } from "bun";
19
+
20
+ const TEST_PORT = 19877 + 4; // avoid colliding with page-proxy.test.ts
21
+ const TEST_DB_PATH = `/tmp/test-kv-page-proxy-${Date.now()}.sqlite`;
22
+ const BASE = `http://localhost:${TEST_PORT}`;
23
+ const API_KEY = "test-kv-page-proxy-key";
24
+ const PAGE_SECRET = "test-kv-page-proxy-secret";
25
+
26
+ let serverProc: Subprocess;
27
+ const agentId = randomUUID();
28
+
29
+ async function waitForServer(url: string, timeoutMs = 15000) {
30
+ const start = Date.now();
31
+ while (Date.now() - start < timeoutMs) {
32
+ try {
33
+ const r = await fetch(url);
34
+ if (r.ok) return;
35
+ } catch {}
36
+ await Bun.sleep(50);
37
+ }
38
+ throw new Error(`Server did not start within ${timeoutMs}ms`);
39
+ }
40
+
41
+ beforeAll(async () => {
42
+ for (const suffix of ["", "-wal", "-shm"]) {
43
+ try {
44
+ await unlink(`${TEST_DB_PATH}${suffix}`);
45
+ } catch {}
46
+ }
47
+
48
+ process.env.PAGE_SESSION_SECRET = PAGE_SECRET;
49
+ serverProc = Bun.spawn(["bun", "src/http.ts"], {
50
+ cwd: `${import.meta.dir}/../..`,
51
+ env: {
52
+ ...process.env,
53
+ PORT: String(TEST_PORT),
54
+ DATABASE_PATH: TEST_DB_PATH,
55
+ API_KEY,
56
+ PAGE_SESSION_SECRET: PAGE_SECRET,
57
+ MCP_BASE_URL: `http://127.0.0.1:${TEST_PORT}`,
58
+ CAPABILITIES: "core,task-pool,messaging,profiles,services,scheduling,memory,pages,kv",
59
+ SLACK_BOT_TOKEN: "",
60
+ GITHUB_WEBHOOK_SECRET: "",
61
+ AGENTMAIL_API_KEY: "",
62
+ },
63
+ stdout: "ignore",
64
+ stderr: "ignore",
65
+ });
66
+ await waitForServer(`${BASE}/health`);
67
+
68
+ const reg = await fetch(`${BASE}/api/agents`, {
69
+ method: "POST",
70
+ headers: {
71
+ "Content-Type": "application/json",
72
+ Authorization: `Bearer ${API_KEY}`,
73
+ "X-Agent-ID": agentId,
74
+ },
75
+ body: JSON.stringify({
76
+ name: "KvPageOwner",
77
+ isLead: false,
78
+ description: "owns the kv page",
79
+ role: "worker",
80
+ capabilities: ["core", "pages", "kv"],
81
+ maxTasks: 1,
82
+ }),
83
+ });
84
+ if (reg.status !== 201 && reg.status !== 200) {
85
+ throw new Error(`Failed to register agent: ${reg.status} ${await reg.text()}`);
86
+ }
87
+ }, 20000);
88
+
89
+ afterAll(async () => {
90
+ if (serverProc) {
91
+ serverProc.kill();
92
+ try {
93
+ await serverProc.exited;
94
+ } catch {}
95
+ }
96
+ await Bun.sleep(50);
97
+ for (const suffix of ["", "-wal", "-shm"]) {
98
+ try {
99
+ await unlink(`${TEST_DB_PATH}${suffix}`);
100
+ } catch {}
101
+ }
102
+ });
103
+
104
+ async function createPage(): Promise<string> {
105
+ const res = await fetch(`${BASE}/api/pages`, {
106
+ method: "POST",
107
+ headers: {
108
+ "Content-Type": "application/json",
109
+ Authorization: `Bearer ${API_KEY}`,
110
+ "X-Agent-ID": agentId,
111
+ },
112
+ body: JSON.stringify({
113
+ slug: `kv-${randomUUID().slice(0, 8)}`,
114
+ title: "KV Proxy Test",
115
+ contentType: "text/html",
116
+ authMode: "public", // /launch issues a cookie regardless of mode
117
+ body: "<h1>kv test</h1>",
118
+ }),
119
+ });
120
+ expect(res.status).toBe(201);
121
+ const json = (await res.json()) as { id: string };
122
+ return json.id;
123
+ }
124
+
125
+ async function launchPage(pageId: string): Promise<string> {
126
+ const res = await fetch(`${BASE}/api/pages/${pageId}/launch`, {
127
+ method: "POST",
128
+ headers: { Authorization: `Bearer ${API_KEY}`, "X-Agent-ID": agentId },
129
+ });
130
+ expect(res.status).toBe(204);
131
+ const setCookie = res.headers.get("set-cookie");
132
+ expect(setCookie).toBeTruthy();
133
+ const cookieValue = /page_session=([^;]+)/.exec(setCookie!)?.[1];
134
+ expect(cookieValue).toBeTruthy();
135
+ return cookieValue!;
136
+ }
137
+
138
+ describe("page proxy → kv", () => {
139
+ test("writes via the proxy land in task:page:<id> automatically", async () => {
140
+ const id = await createPage();
141
+ const cookie = await launchPage(id);
142
+
143
+ const put = await fetch(`${BASE}/@swarm/api/kv/clicks`, {
144
+ method: "PUT",
145
+ headers: {
146
+ "Content-Type": "application/json",
147
+ Cookie: `page_session=${cookie}`,
148
+ },
149
+ body: JSON.stringify({ value: 1, valueType: "integer" }),
150
+ });
151
+ expect(put.status).toBe(200);
152
+ const stored = (await put.json()) as { namespace: string; value: number };
153
+ expect(stored.namespace).toBe(`task:page:${id}`);
154
+ expect(stored.value).toBe(1);
155
+
156
+ // Reading server-side with bearer + the explicit page ns sees the same row.
157
+ const directGet = await fetch(
158
+ `${BASE}/api/kv/_/${encodeURIComponent(`task:page:${id}`)}/clicks`,
159
+ {
160
+ headers: { Authorization: `Bearer ${API_KEY}`, "X-Agent-ID": agentId },
161
+ },
162
+ );
163
+ expect(directGet.status).toBe(200);
164
+ });
165
+
166
+ test("INCR via the proxy works on the page namespace", async () => {
167
+ const id = await createPage();
168
+ const cookie = await launchPage(id);
169
+ const r1 = await fetch(`${BASE}/@swarm/api/kv/votes/incr`, {
170
+ method: "POST",
171
+ headers: { "Content-Type": "application/json", Cookie: `page_session=${cookie}` },
172
+ body: JSON.stringify({ by: 2 }),
173
+ });
174
+ expect(r1.status).toBe(200);
175
+ const r2 = await fetch(`${BASE}/@swarm/api/kv/votes/incr`, {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/json", Cookie: `page_session=${cookie}` },
178
+ body: JSON.stringify({}),
179
+ });
180
+ const v = (await r2.json()) as { value: number; namespace: string };
181
+ expect(v.value).toBe(3);
182
+ expect(v.namespace).toBe(`task:page:${id}`);
183
+ });
184
+
185
+ test("page can't escape its own namespace even with an explicit /_/<other-ns>/... path", async () => {
186
+ const id = await createPage();
187
+ const cookie = await launchPage(id);
188
+
189
+ // Try to write to a completely different namespace via the explicit
190
+ // URL shape. The proxy's X-Page-Id should force task:page:<id>.
191
+ const fakeNs = `task:agent:${agentId}`;
192
+ const put = await fetch(`${BASE}/@swarm/api/kv/_/${encodeURIComponent(fakeNs)}/escape`, {
193
+ method: "PUT",
194
+ headers: { "Content-Type": "application/json", Cookie: `page_session=${cookie}` },
195
+ body: JSON.stringify({ value: "leaked", valueType: "string" }),
196
+ });
197
+ // The kv handler treats X-Page-Id as the highest-priority namespace
198
+ // source. For the *explicit-ns variant*, the URL still resolves a route,
199
+ // but the proxy-injected X-Page-Id signals page-mode auth. To keep the
200
+ // strict rule "pages never write anything except task:page:<own>", the
201
+ // request must either succeed under the page namespace OR be rejected.
202
+ // We assert that the entry, if created, lives under task:page:<id>,
203
+ // and that the supposedly-target agent namespace contains nothing.
204
+ expect([200, 403]).toContain(put.status);
205
+
206
+ // The fake target ns must NOT have been written.
207
+ const verify = await fetch(`${BASE}/api/kv/_/${encodeURIComponent(fakeNs)}/escape`, {
208
+ headers: { Authorization: `Bearer ${API_KEY}`, "X-Agent-ID": agentId },
209
+ });
210
+ expect(verify.status).toBe(404);
211
+ });
212
+ });
@@ -0,0 +1,227 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ closeDb,
5
+ countKv,
6
+ deleteKv,
7
+ getDb,
8
+ getKv,
9
+ incrKv,
10
+ initDb,
11
+ KvTypeCollisionError,
12
+ listKv,
13
+ upsertKv,
14
+ } from "../be/db";
15
+
16
+ const TEST_DB_PATH = "./test-kv-storage.sqlite";
17
+
18
+ const NS = "task:agent:test-agent";
19
+
20
+ async function clearDb() {
21
+ for (const suffix of ["", "-wal", "-shm"]) {
22
+ try {
23
+ await unlink(TEST_DB_PATH + suffix);
24
+ } catch {}
25
+ }
26
+ }
27
+
28
+ describe("kv-storage helpers", () => {
29
+ beforeAll(async () => {
30
+ await clearDb();
31
+ initDb(TEST_DB_PATH);
32
+ });
33
+
34
+ afterAll(async () => {
35
+ closeDb();
36
+ await clearDb();
37
+ });
38
+
39
+ beforeEach(() => {
40
+ // Tests use distinct keys per case; nothing to wipe between tests.
41
+ getDb().run(`DELETE FROM kv_entries WHERE namespace = ?`, [NS]);
42
+ });
43
+
44
+ test("get returns null for missing keys", () => {
45
+ expect(getKv(NS, "missing")).toBeNull();
46
+ });
47
+
48
+ test("upsertKv + getKv round-trip json values", () => {
49
+ const entry = upsertKv({
50
+ namespace: NS,
51
+ key: "obj",
52
+ value: { a: 1, b: ["two", 3] },
53
+ valueType: "json",
54
+ });
55
+ expect(entry.value).toEqual({ a: 1, b: ["two", 3] });
56
+ expect(entry.valueType).toBe("json");
57
+
58
+ const read = getKv(NS, "obj");
59
+ expect(read?.value).toEqual({ a: 1, b: ["two", 3] });
60
+ });
61
+
62
+ test("upsertKv overwrites the existing row in place", () => {
63
+ upsertKv({ namespace: NS, key: "k", value: "first", valueType: "string" });
64
+ const second = upsertKv({ namespace: NS, key: "k", value: "second", valueType: "string" });
65
+ expect(second.value).toBe("second");
66
+ expect(getKv(NS, "k")?.value).toBe("second");
67
+ });
68
+
69
+ test("string value type stores raw bytes", () => {
70
+ upsertKv({ namespace: NS, key: "s", value: 'hello "world"', valueType: "string" });
71
+ const got = getKv(NS, "s");
72
+ expect(got?.value).toBe('hello "world"');
73
+ expect(got?.valueType).toBe("string");
74
+ });
75
+
76
+ test("integer value type stores as number", () => {
77
+ upsertKv({ namespace: NS, key: "n", value: 42, valueType: "integer" });
78
+ expect(getKv(NS, "n")?.value).toBe(42);
79
+ });
80
+
81
+ test("deleteKv removes and returns true; second delete returns false", () => {
82
+ upsertKv({ namespace: NS, key: "del", value: 1, valueType: "integer" });
83
+ expect(deleteKv(NS, "del")).toBe(true);
84
+ expect(deleteKv(NS, "del")).toBe(false);
85
+ expect(getKv(NS, "del")).toBeNull();
86
+ });
87
+
88
+ test("TTL: expired key returns null on read AND is deleted from row store", () => {
89
+ upsertKv({
90
+ namespace: NS,
91
+ key: "ttl",
92
+ value: "soon",
93
+ valueType: "string",
94
+ expiresAt: Date.now() - 1, // already expired
95
+ });
96
+ expect(getKv(NS, "ttl")).toBeNull();
97
+ // Row should have been deleted by the lazy sweep
98
+ const raw = getDb()
99
+ .prepare<{ key: string }, [string, string]>(
100
+ `SELECT key FROM kv_entries WHERE namespace = ? AND key = ?`,
101
+ )
102
+ .get(NS, "ttl");
103
+ expect(raw).toBeNull();
104
+ });
105
+
106
+ test("TTL: non-expired keys are returned normally", () => {
107
+ upsertKv({
108
+ namespace: NS,
109
+ key: "live",
110
+ value: "now",
111
+ valueType: "string",
112
+ expiresAt: Date.now() + 60_000,
113
+ });
114
+ expect(getKv(NS, "live")?.value).toBe("now");
115
+ });
116
+
117
+ test("listKv filters expired but does not delete them inline", () => {
118
+ upsertKv({
119
+ namespace: NS,
120
+ key: "exp",
121
+ value: "x",
122
+ valueType: "string",
123
+ expiresAt: Date.now() - 1,
124
+ });
125
+ upsertKv({ namespace: NS, key: "alive", value: "x", valueType: "string" });
126
+ const all = listKv(NS, { limit: 100, offset: 0 });
127
+ expect(all.map((e) => e.key)).toEqual(["alive"]);
128
+ // The expired row should still exist on disk because listKv doesn't sweep.
129
+ const stillThere = getDb()
130
+ .prepare<{ key: string }, [string, string]>(
131
+ `SELECT key FROM kv_entries WHERE namespace = ? AND key = ?`,
132
+ )
133
+ .get(NS, "exp");
134
+ expect(stillThere?.key).toBe("exp");
135
+ });
136
+
137
+ test("listKv prefix filter & ordering", () => {
138
+ upsertKv({ namespace: NS, key: "a-1", value: 1, valueType: "integer" });
139
+ upsertKv({ namespace: NS, key: "a-2", value: 2, valueType: "integer" });
140
+ upsertKv({ namespace: NS, key: "b-1", value: 3, valueType: "integer" });
141
+ const a = listKv(NS, { prefix: "a-", limit: 100, offset: 0 });
142
+ expect(a.map((e) => e.key)).toEqual(["a-1", "a-2"]);
143
+ expect(countKv(NS, { prefix: "a-" })).toBe(2);
144
+ expect(countKv(NS, {})).toBe(3);
145
+ });
146
+
147
+ test("listKv prefix escapes SQL LIKE wildcards", () => {
148
+ upsertKv({ namespace: NS, key: "x_1", value: 1, valueType: "integer" });
149
+ upsertKv({ namespace: NS, key: "xyz", value: 2, valueType: "integer" });
150
+ const exact = listKv(NS, { prefix: "x_", limit: 100, offset: 0 });
151
+ // Without escaping, `_` would match any char and we'd get both rows.
152
+ expect(exact.map((e) => e.key)).toEqual(["x_1"]);
153
+ });
154
+
155
+ test("incrKv creates from missing", () => {
156
+ const entry = incrKv(NS, "counter", 3);
157
+ expect(entry.value).toBe(3);
158
+ expect(entry.valueType).toBe("integer");
159
+ });
160
+
161
+ test("incrKv increments existing integer", () => {
162
+ incrKv(NS, "counter", 1);
163
+ incrKv(NS, "counter", 4);
164
+ const entry = incrKv(NS, "counter", -2);
165
+ expect(entry.value).toBe(3);
166
+ });
167
+
168
+ test("incrKv treats expired row as missing", () => {
169
+ upsertKv({
170
+ namespace: NS,
171
+ key: "decay",
172
+ value: 100,
173
+ valueType: "integer",
174
+ expiresAt: Date.now() - 1,
175
+ });
176
+ const entry = incrKv(NS, "decay", 5);
177
+ expect(entry.value).toBe(5);
178
+ expect(entry.expiresAt).toBeNull();
179
+ });
180
+
181
+ test("incrKv collides with json valueType (409 surface)", () => {
182
+ upsertKv({ namespace: NS, key: "obj", value: { n: 1 }, valueType: "json" });
183
+ let thrown: unknown;
184
+ try {
185
+ incrKv(NS, "obj", 1);
186
+ } catch (err) {
187
+ thrown = err;
188
+ }
189
+ expect(thrown).toBeInstanceOf(KvTypeCollisionError);
190
+ if (thrown instanceof KvTypeCollisionError) {
191
+ expect(thrown.existingType).toBe("json");
192
+ }
193
+ });
194
+
195
+ test("incrKv collides with string valueType", () => {
196
+ upsertKv({ namespace: NS, key: "str", value: "5", valueType: "string" });
197
+ expect(() => incrKv(NS, "str", 1)).toThrow(KvTypeCollisionError);
198
+ });
199
+
200
+ test("2 MiB exactly succeeds; 2 MiB + 1 byte rejected via upsert encoder is N/A — boundary lives in HTTP/MCP layer", () => {
201
+ // The DB helpers themselves don't enforce size — that's the HTTP/MCP
202
+ // boundary. But we can store a 2 MiB string here to prove the engine
203
+ // accepts it. The 2 MiB + 1 case is covered by the HTTP test.
204
+ const twoMiB = "x".repeat(2 * 1024 * 1024);
205
+ const entry = upsertKv({ namespace: NS, key: "big", value: twoMiB, valueType: "string" });
206
+ expect((entry.value as string).length).toBe(2 * 1024 * 1024);
207
+ });
208
+ });
209
+
210
+ describe("kv-storage namespaces are isolated", () => {
211
+ beforeAll(async () => {
212
+ await clearDb();
213
+ initDb(TEST_DB_PATH);
214
+ });
215
+
216
+ afterAll(async () => {
217
+ closeDb();
218
+ await clearDb();
219
+ });
220
+
221
+ test("different namespaces with same key are independent", () => {
222
+ upsertKv({ namespace: "task:agent:a", key: "shared", value: "A", valueType: "string" });
223
+ upsertKv({ namespace: "task:agent:b", key: "shared", value: "B", valueType: "string" });
224
+ expect(getKv("task:agent:a", "shared")?.value).toBe("A");
225
+ expect(getKv("task:agent:b", "shared")?.value).toBe("B");
226
+ });
227
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * KV MCP tools — unit-level coverage. Registers each tool against a fresh
3
+ * McpServer, pulls handlers out of the SDK registry, invokes them with a
4
+ * stubbed `requestInfo` (mirrors create-page-tool.test.ts).
5
+ *
6
+ * Verifies:
7
+ * - kv-set / kv-get round-trip on the auto-resolved agent namespace
8
+ * - kv-incr atomicity + 'integer' coercion
9
+ * - kv-list shape (entries, total, namespace)
10
+ * - kv-delete returns deleted flag
11
+ * - cross-agent write 403 (lead bypass tested too)
12
+ * - missing namespace + no agent header → structured error
13
+ */
14
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
+ import { unlink } from "node:fs/promises";
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { closeDb, createAgent, getDb, initDb } from "../be/db";
18
+ import {
19
+ registerKvDeleteTool,
20
+ registerKvGetTool,
21
+ registerKvIncrTool,
22
+ registerKvListTool,
23
+ registerKvSetTool,
24
+ } from "../tools/kv";
25
+
26
+ const TEST_DB_PATH = "./test-kv-tool.sqlite";
27
+
28
+ type RegisteredTool = {
29
+ handler: (args: unknown, extra: unknown) => Promise<unknown>;
30
+ };
31
+
32
+ function buildServer() {
33
+ const server = new McpServer({ name: "kv-tool-test", version: "1.0.0" });
34
+ registerKvGetTool(server);
35
+ registerKvSetTool(server);
36
+ registerKvDeleteTool(server);
37
+ registerKvIncrTool(server);
38
+ registerKvListTool(server);
39
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
40
+ ._registeredTools;
41
+ return {
42
+ get: registered["kv-get"]!,
43
+ set: registered["kv-set"]!,
44
+ del: registered["kv-delete"]!,
45
+ incr: registered["kv-incr"]!,
46
+ list: registered["kv-list"]!,
47
+ };
48
+ }
49
+
50
+ function meta(agentId: string | undefined, sourceTaskId?: string) {
51
+ const headers: Record<string, string> = {};
52
+ if (agentId !== undefined) headers["x-agent-id"] = agentId;
53
+ if (sourceTaskId !== undefined) headers["x-source-task-id"] = sourceTaskId;
54
+ return { sessionId: "s1", requestInfo: { headers } };
55
+ }
56
+
57
+ type StructuredResult<T> = { structuredContent: T };
58
+
59
+ let agentA: string;
60
+ let agentB: string;
61
+ let lead: string;
62
+
63
+ beforeAll(async () => {
64
+ for (const suffix of ["", "-wal", "-shm"]) {
65
+ try {
66
+ await unlink(`${TEST_DB_PATH}${suffix}`);
67
+ } catch {}
68
+ }
69
+ initDb(TEST_DB_PATH);
70
+ const a = createAgent({ name: "kv-tool-a", isLead: false, status: "idle" });
71
+ const b = createAgent({ name: "kv-tool-b", isLead: false, status: "idle" });
72
+ const l = createAgent({ name: "kv-tool-lead", isLead: true, status: "idle" });
73
+ agentA = a.id;
74
+ agentB = b.id;
75
+ lead = l.id;
76
+ });
77
+
78
+ afterAll(async () => {
79
+ closeDb();
80
+ for (const suffix of ["", "-wal", "-shm"]) {
81
+ try {
82
+ await unlink(`${TEST_DB_PATH}${suffix}`);
83
+ } catch {}
84
+ }
85
+ });
86
+
87
+ beforeEach(() => {
88
+ getDb().run("DELETE FROM kv_entries");
89
+ });
90
+
91
+ describe("kv MCP tools", () => {
92
+ test("kv-set + kv-get round-trip on agent namespace", async () => {
93
+ const tools = buildServer();
94
+ const setRes = (await tools.set.handler(
95
+ { key: "k1", value: { hello: "world" } },
96
+ meta(agentA),
97
+ )) as StructuredResult<{
98
+ success: boolean;
99
+ namespace: string;
100
+ entry: { value: unknown; valueType: string };
101
+ }>;
102
+ expect(setRes.structuredContent.success).toBe(true);
103
+ expect(setRes.structuredContent.namespace).toBe(`task:agent:${agentA}`);
104
+ expect(setRes.structuredContent.entry.value).toEqual({ hello: "world" });
105
+
106
+ const getRes = (await tools.get.handler({ key: "k1" }, meta(agentA))) as StructuredResult<{
107
+ success: boolean;
108
+ entry: { value: unknown } | null;
109
+ }>;
110
+ expect(getRes.structuredContent.success).toBe(true);
111
+ expect(getRes.structuredContent.entry?.value).toEqual({ hello: "world" });
112
+ });
113
+
114
+ test("kv-get returns entry=null for missing keys", async () => {
115
+ const tools = buildServer();
116
+ const getRes = (await tools.get.handler({ key: "nope" }, meta(agentA))) as StructuredResult<{
117
+ success: boolean;
118
+ entry: unknown | null;
119
+ }>;
120
+ expect(getRes.structuredContent.success).toBe(true);
121
+ expect(getRes.structuredContent.entry).toBeNull();
122
+ });
123
+
124
+ test("kv-incr creates + increments + reports value", async () => {
125
+ const tools = buildServer();
126
+ const r1 = (await tools.incr.handler({ key: "ctr", by: 5 }, meta(agentA))) as StructuredResult<{
127
+ entry: { value: number; valueType: string };
128
+ }>;
129
+ expect(r1.structuredContent.entry.value).toBe(5);
130
+ expect(r1.structuredContent.entry.valueType).toBe("integer");
131
+ const r2 = (await tools.incr.handler({ key: "ctr" }, meta(agentA))) as StructuredResult<{
132
+ entry: { value: number };
133
+ }>;
134
+ expect(r2.structuredContent.entry.value).toBe(6);
135
+ });
136
+
137
+ test("kv-incr returns structured error on valueType collision", async () => {
138
+ const tools = buildServer();
139
+ await tools.set.handler({ key: "obj", value: { n: 1 } }, meta(agentA));
140
+ const r = (await tools.incr.handler({ key: "obj" }, meta(agentA))) as StructuredResult<{
141
+ success: boolean;
142
+ message: string;
143
+ }>;
144
+ expect(r.structuredContent.success).toBe(false);
145
+ expect(r.structuredContent.message).toMatch(/Cannot INCR/);
146
+ });
147
+
148
+ test("kv-list returns entries + total + namespace", async () => {
149
+ const tools = buildServer();
150
+ await tools.set.handler({ key: "a-1", value: 1, valueType: "integer" }, meta(agentA));
151
+ await tools.set.handler({ key: "a-2", value: 2, valueType: "integer" }, meta(agentA));
152
+ await tools.set.handler({ key: "b-1", value: 3, valueType: "integer" }, meta(agentA));
153
+
154
+ const r = (await tools.list.handler({ prefix: "a-" }, meta(agentA))) as StructuredResult<{
155
+ success: boolean;
156
+ entries: { key: string }[];
157
+ total: number;
158
+ namespace: string;
159
+ }>;
160
+ expect(r.structuredContent.entries.map((e) => e.key)).toEqual(["a-1", "a-2"]);
161
+ expect(r.structuredContent.total).toBe(2);
162
+ expect(r.structuredContent.namespace).toBe(`task:agent:${agentA}`);
163
+ });
164
+
165
+ test("kv-delete returns deleted flag", async () => {
166
+ const tools = buildServer();
167
+ await tools.set.handler({ key: "del-me", value: "x", valueType: "string" }, meta(agentA));
168
+ const r1 = (await tools.del.handler({ key: "del-me" }, meta(agentA))) as StructuredResult<{
169
+ deleted: boolean;
170
+ }>;
171
+ expect(r1.structuredContent.deleted).toBe(true);
172
+ const r2 = (await tools.del.handler({ key: "del-me" }, meta(agentA))) as StructuredResult<{
173
+ deleted: boolean;
174
+ }>;
175
+ expect(r2.structuredContent.deleted).toBe(false);
176
+ });
177
+
178
+ test("cross-agent write is rejected for non-lead callers", async () => {
179
+ const tools = buildServer();
180
+ const r = (await tools.set.handler(
181
+ { key: "k", value: 1, namespace: `task:agent:${agentB}` },
182
+ meta(agentA),
183
+ )) as StructuredResult<{ success: boolean; message: string }>;
184
+ expect(r.structuredContent.success).toBe(false);
185
+ expect(r.structuredContent.message).toMatch(/lead/);
186
+ });
187
+
188
+ test("lead can write to another agent's namespace", async () => {
189
+ const tools = buildServer();
190
+ const r = (await tools.set.handler(
191
+ { key: "k", value: 1, namespace: `task:agent:${agentB}`, valueType: "integer" },
192
+ meta(lead),
193
+ )) as StructuredResult<{ success: boolean; namespace: string }>;
194
+ expect(r.structuredContent.success).toBe(true);
195
+ expect(r.structuredContent.namespace).toBe(`task:agent:${agentB}`);
196
+ });
197
+
198
+ test("missing agent header → namespace cannot be resolved", async () => {
199
+ const tools = buildServer();
200
+ const r = (await tools.get.handler({ key: "k" }, meta(undefined))) as StructuredResult<{
201
+ success: boolean;
202
+ message: string;
203
+ }>;
204
+ expect(r.structuredContent.success).toBe(false);
205
+ expect(r.structuredContent.message).toMatch(/namespace/);
206
+ });
207
+
208
+ test("page namespace writes are rejected (MCP can't be a page)", async () => {
209
+ const tools = buildServer();
210
+ const r = (await tools.set.handler(
211
+ { key: "k", value: 1, namespace: "task:page:doesntmatter" },
212
+ meta(agentA),
213
+ )) as StructuredResult<{ success: boolean; message: string }>;
214
+ expect(r.structuredContent.success).toBe(false);
215
+ expect(r.structuredContent.message).toMatch(/page-proxy/);
216
+ });
217
+ });
@@ -226,7 +226,11 @@ describe("/@swarm/api/* proxy", () => {
226
226
  const exp = Math.floor(Date.now() / 1000) + 3600;
227
227
  const good = await signPageSession({ pageId: id, exp });
228
228
  const [head, sig] = good.split(".");
229
- const tamperedSig = `${sig!.slice(0, -1)}${sig!.slice(-1) === "A" ? "B" : "A"}`;
229
+ // Flip a decoded HMAC byte rather than a base64url char — flipping the
230
+ // last char is flaky (see src/tests/page-session.test.ts for why).
231
+ const sigBytes = Buffer.from(sig!, "base64url");
232
+ sigBytes[0] ^= 0x01;
233
+ const tamperedSig = sigBytes.toString("base64url").replace(/=/g, "");
230
234
  const bad = `${head}.${tamperedSig}`;
231
235
  const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
232
236
  headers: { Cookie: `page_session=${bad}` },
@@ -59,11 +59,16 @@ describe("page-session HMAC helpers", () => {
59
59
  const token = await signPageSession(payload);
60
60
  const [head, sig] = token.split(".");
61
61
  expect(sig).toBeDefined();
62
- // Flip the last character keeps length identical so we exercise the
63
- // constant-time compare branch (not the length-mismatch early-return).
64
- const lastChar = sig!.slice(-1);
65
- const flipped = lastChar === "A" ? "B" : "A";
66
- const tamperedSig = sig!.slice(0, -1) + flipped;
62
+ // Flip a decoded HMAC byte rather than a base64url character. Flipping the
63
+ // last *character* is flaky: for a 32-byte (SHA-256) HMAC the final base64url
64
+ // char encodes 4 real bits + 2 LSB padding zeros, so "A"→"B" only toggles a
65
+ // padding bit and the decoded HMAC is unchanged (~1/16 probability), causing
66
+ // the verifier to incorrectly accept the token. Operating on decoded bytes is
67
+ // deterministic and still produces a same-length re-encoded token, exercising
68
+ // the constant-time compare branch (not the length-mismatch early-return).
69
+ const sigBytes = Buffer.from(sig!, "base64url");
70
+ sigBytes[0] ^= 0x01;
71
+ const tamperedSig = sigBytes.toString("base64url").replace(/=/g, "");
67
72
  const tampered = `${head}.${tamperedSig}`;
68
73
  expect(await verifyPageSession(tampered)).toBeNull();
69
74
  });