@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.
- package/README.md +2 -0
- package/openapi.json +559 -1
- package/package.json +4 -4
- package/plugin/skills/kv-storage/SKILL.md +168 -0
- package/plugin/skills/pages/SKILL.md +149 -0
- package/src/artifact-sdk/browser-sdk.ts +292 -0
- package/src/be/db.ts +309 -0
- package/src/be/migrations/061_kv_store.sql +34 -0
- package/src/be/migrations/062_pages_view_count.sql +9 -0
- package/src/commands/provider-credentials.ts +1 -1
- package/src/http/index.ts +2 -0
- package/src/http/kv.ts +658 -0
- package/src/http/page-proxy.ts +5 -0
- package/src/http/pages-public.ts +50 -6
- package/src/http/status.ts +1 -1
- package/src/providers/claude-adapter.ts +138 -7
- package/src/providers/pi-mono-adapter.ts +3 -3
- package/src/providers/pi-mono-extension.ts +1 -1
- package/src/server.ts +20 -1
- package/src/tasks/context-key.ts +28 -0
- package/src/telemetry.ts +65 -1
- package/src/tests/claude-adapter-binary.test.ts +628 -0
- package/src/tests/context-key.test.ts +17 -0
- package/src/tests/kv-http.test.ts +331 -0
- package/src/tests/kv-namespace-resolution.test.ts +172 -0
- package/src/tests/kv-page-proxy.test.ts +212 -0
- package/src/tests/kv-storage.test.ts +227 -0
- package/src/tests/kv-tool.test.ts +217 -0
- package/src/tests/page-proxy.test.ts +5 -1
- package/src/tests/page-session.test.ts +10 -5
- package/src/tests/pages-authed-mode.test.ts +5 -1
- package/src/tests/pages-public-html.test.ts +10 -1
- package/src/tests/pages-view-count.test.ts +220 -0
- package/src/tests/swarm-diff.test.ts +303 -0
- package/src/tests/telemetry-init.test.ts +149 -0
- package/src/tools/kv/index.ts +5 -0
- package/src/tools/kv/kv-delete.ts +89 -0
- package/src/tools/kv/kv-get.ts +64 -0
- package/src/tools/kv/kv-incr.ts +116 -0
- package/src/tools/kv/kv-list.ts +81 -0
- package/src/tools/kv/kv-set.ts +194 -0
- package/src/tools/kv/resolve-namespace.ts +58 -0
- package/src/tools/tool-config.ts +7 -0
- package/src/types.ts +53 -0
- package/src/utils/internal-ai/complete-structured.ts +7 -10
- 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
|
-
|
|
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
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
});
|