@desplega.ai/agent-swarm 1.78.1 → 1.79.0
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/openapi.json +542 -1
- package/package.json +1 -1
- package/plugin/skills/artifacts/SKILL.md +151 -0
- package/plugin/skills/artifacts/examples/static-report.sh +1 -1
- package/plugin/skills/pages/SKILL.md +274 -0
- package/src/artifact-sdk/browser-sdk.ts +105 -20
- package/src/be/db.ts +239 -0
- package/src/be/migrations/059_pages.sql +34 -0
- package/src/be/migrations/060_page_versions.sql +19 -0
- package/src/commands/artifact.ts +17 -11
- package/src/http/index.ts +7 -1
- package/src/http/page-proxy.ts +208 -0
- package/src/http/pages-public.ts +466 -0
- package/src/http/pages.ts +608 -0
- package/src/http/utils.ts +68 -5
- package/src/pages/version.ts +44 -0
- package/src/prompts/session-templates.ts +51 -0
- package/src/server.ts +10 -1
- package/src/tests/artifact-commands.test.ts +92 -0
- package/src/tests/artifact-sdk.test.ts +80 -74
- package/src/tests/create-page-tool.test.ts +197 -0
- package/src/tests/fixtures/sample-json-page.json +52 -0
- package/src/tests/launch-password-rejection.test.ts +139 -0
- package/src/tests/page-proxy-authed.test.ts +146 -0
- package/src/tests/page-proxy.test.ts +266 -0
- package/src/tests/page-session.test.ts +164 -0
- package/src/tests/pages-actions-endpoint.test.ts +102 -0
- package/src/tests/pages-authed-mode.test.ts +207 -0
- package/src/tests/pages-http.test.ts +193 -0
- package/src/tests/pages-list-endpoint.test.ts +149 -0
- package/src/tests/pages-password-hash.test.ts +57 -0
- package/src/tests/pages-password-mode.test.ts +265 -0
- package/src/tests/pages-public-authed-401.test.ts +102 -0
- package/src/tests/pages-public-html.test.ts +151 -0
- package/src/tests/pages-public-json-redirect.test.ts +86 -0
- package/src/tests/pages-storage.test.ts +196 -0
- package/src/tests/pages-versioning.test.ts +231 -0
- package/src/tests/prompt-template-session.test.ts +3 -2
- package/src/tests/skill-update-scope.test.ts +165 -0
- package/src/tests/workflow-wait-event.test.ts +4 -7
- package/src/tools/create-page.ts +263 -0
- package/src/tools/skills/skill-update.ts +26 -0
- package/src/tools/tool-config.ts +3 -0
- package/src/types.ts +54 -0
- package/src/utils/page-session.ts +254 -0
- package/plugin/skills/artifacts/skill.md +0 -70
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"root": "root",
|
|
3
|
+
"elements": {
|
|
4
|
+
"root": {
|
|
5
|
+
"type": "Container",
|
|
6
|
+
"props": { "direction": "column", "gap": "md" },
|
|
7
|
+
"children": ["heading", "intro", "sdkBtn", "callBtn"]
|
|
8
|
+
},
|
|
9
|
+
"heading": {
|
|
10
|
+
"type": "Heading",
|
|
11
|
+
"props": { "text": "Sample JSON page", "level": "h1" },
|
|
12
|
+
"children": []
|
|
13
|
+
},
|
|
14
|
+
"intro": {
|
|
15
|
+
"type": "Text",
|
|
16
|
+
"props": {
|
|
17
|
+
"content": "Two buttons below dispatch swarm.sdk and swarm.call actions. Click each to exercise the renderer's action wiring.",
|
|
18
|
+
"tone": "muted"
|
|
19
|
+
},
|
|
20
|
+
"children": []
|
|
21
|
+
},
|
|
22
|
+
"sdkBtn": {
|
|
23
|
+
"type": "Button",
|
|
24
|
+
"props": { "label": "Create task via SDK" },
|
|
25
|
+
"children": [],
|
|
26
|
+
"on": {
|
|
27
|
+
"press": {
|
|
28
|
+
"action": "swarm.sdk",
|
|
29
|
+
"params": {
|
|
30
|
+
"sdk": "createTask",
|
|
31
|
+
"args": { "description": "from-json-page" }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"callBtn": {
|
|
37
|
+
"type": "Button",
|
|
38
|
+
"props": { "label": "Create channel via raw call", "variant": "secondary" },
|
|
39
|
+
"children": [],
|
|
40
|
+
"on": {
|
|
41
|
+
"press": {
|
|
42
|
+
"action": "swarm.call",
|
|
43
|
+
"params": {
|
|
44
|
+
"method": "POST",
|
|
45
|
+
"endpoint": "/api/channels",
|
|
46
|
+
"body": { "name": "from-json-page" }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /api/pages/:id/launch` must reject `auth_mode='password'` pages with
|
|
3
|
+
* 400 — password pages mint their own cookie out of the public `/p/:id`
|
|
4
|
+
* route (step-5) after verifying the password. Letting a bearer-only caller
|
|
5
|
+
* mint a cookie via `/launch` would bypass the password check entirely.
|
|
6
|
+
*
|
|
7
|
+
* In-process variant of the launch endpoint; matches the test wiring used by
|
|
8
|
+
* `pages-public-html.test.ts` and friends.
|
|
9
|
+
*/
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import { unlink } from "node:fs/promises";
|
|
13
|
+
import {
|
|
14
|
+
createServer as createHttpServer,
|
|
15
|
+
type IncomingMessage,
|
|
16
|
+
type Server,
|
|
17
|
+
type ServerResponse,
|
|
18
|
+
} from "node:http";
|
|
19
|
+
import { closeDb, initDb } from "../be/db";
|
|
20
|
+
import { handlePages } from "../http/pages";
|
|
21
|
+
import { handlePagesPublic } from "../http/pages-public";
|
|
22
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
23
|
+
|
|
24
|
+
const TEST_DB_PATH = "./test-launch-password-rejection.sqlite";
|
|
25
|
+
const TEST_PORT = 13050;
|
|
26
|
+
const BASE = `http://localhost:${TEST_PORT}`;
|
|
27
|
+
|
|
28
|
+
function createTestServer(): Server {
|
|
29
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
30
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
31
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
32
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
33
|
+
if (await handlePagesPublic(req, res, pathSegments, queryParams)) return;
|
|
34
|
+
if (await handlePages(req, res, pathSegments, queryParams, myAgentId)) return;
|
|
35
|
+
res.writeHead(404);
|
|
36
|
+
res.end("not found");
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("POST /api/pages/:id/launch — password mode rejection (step-4)", () => {
|
|
41
|
+
let server: Server;
|
|
42
|
+
const agentId = crypto.randomUUID();
|
|
43
|
+
const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
process.env.PAGE_SESSION_SECRET = "test-launch-password-rejection-secret";
|
|
47
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
48
|
+
try {
|
|
49
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
initDb(TEST_DB_PATH);
|
|
53
|
+
server = createTestServer();
|
|
54
|
+
await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
59
|
+
closeDb();
|
|
60
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
61
|
+
try {
|
|
62
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("password page → launch returns 400 with explanatory error", async () => {
|
|
68
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers,
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
slug: "password-reject",
|
|
73
|
+
title: "Password",
|
|
74
|
+
contentType: "text/html",
|
|
75
|
+
authMode: "password",
|
|
76
|
+
password: "swordfish",
|
|
77
|
+
body: "<h1>locked</h1>",
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
expect(post.status).toBe(201);
|
|
81
|
+
const { id } = (await post.json()) as { id: string };
|
|
82
|
+
|
|
83
|
+
const launch = await fetch(`${BASE}/api/pages/${id}/launch`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "X-Agent-ID": agentId },
|
|
86
|
+
});
|
|
87
|
+
expect(launch.status).toBe(400);
|
|
88
|
+
const body = (await launch.json()) as { error: string };
|
|
89
|
+
expect(body.error).toContain("use ?key=");
|
|
90
|
+
// Confirm no cookie was issued on the rejected launch.
|
|
91
|
+
expect(launch.headers.get("set-cookie")).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("authed page → launch still issues a cookie (negative control)", async () => {
|
|
95
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers,
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
slug: "authed-ok",
|
|
100
|
+
title: "Authed",
|
|
101
|
+
contentType: "text/html",
|
|
102
|
+
authMode: "authed",
|
|
103
|
+
body: "<h1>ok</h1>",
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
expect(post.status).toBe(201);
|
|
107
|
+
const { id } = (await post.json()) as { id: string };
|
|
108
|
+
|
|
109
|
+
const launch = await fetch(`${BASE}/api/pages/${id}/launch`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "X-Agent-ID": agentId },
|
|
112
|
+
});
|
|
113
|
+
expect(launch.status).toBe(204);
|
|
114
|
+
expect(launch.headers.get("set-cookie")).toContain("page_session=");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("public page → launch still issues a cookie (uniform path)", async () => {
|
|
118
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers,
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
slug: "public-ok",
|
|
123
|
+
title: "Public",
|
|
124
|
+
contentType: "text/html",
|
|
125
|
+
authMode: "public",
|
|
126
|
+
body: "<h1>open</h1>",
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
expect(post.status).toBe(201);
|
|
130
|
+
const { id } = (await post.json()) as { id: string };
|
|
131
|
+
|
|
132
|
+
const launch = await fetch(`${BASE}/api/pages/${id}/launch`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "X-Agent-ID": agentId },
|
|
135
|
+
});
|
|
136
|
+
expect(launch.status).toBe(204);
|
|
137
|
+
expect(launch.headers.get("set-cookie")).toContain("page_session=");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extends the step-2 `page-proxy.test.ts` coverage to authed-mode pages.
|
|
3
|
+
*
|
|
4
|
+
* The proxy's auth model is "cookie is the auth" — it does NOT care whether
|
|
5
|
+
* the underlying page is `public`, `authed`, or `password`. This test simply
|
|
6
|
+
* confirms that an `auth_mode='authed'` page survives the same cookie flow:
|
|
7
|
+
* launch → cookie → /@swarm/api/agents/:id → 200 with the page owner's
|
|
8
|
+
* agent record.
|
|
9
|
+
*
|
|
10
|
+
* Spawns the real `src/http.ts` server (mirrors step-2's pattern) so we
|
|
11
|
+
* exercise the bearer gate + cookie + proxy in the same shape production
|
|
12
|
+
* runs.
|
|
13
|
+
*/
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { unlink } from "node:fs/promises";
|
|
17
|
+
import type { Subprocess } from "bun";
|
|
18
|
+
|
|
19
|
+
const TEST_PORT = 19881;
|
|
20
|
+
const TEST_DB_PATH = `/tmp/test-page-proxy-authed-${Date.now()}.sqlite`;
|
|
21
|
+
const BASE = `http://localhost:${TEST_PORT}`;
|
|
22
|
+
const API_KEY = "test-page-proxy-authed-key";
|
|
23
|
+
const PAGE_SECRET = "test-page-proxy-authed-secret";
|
|
24
|
+
|
|
25
|
+
let serverProc: Subprocess;
|
|
26
|
+
const agentId = randomUUID();
|
|
27
|
+
|
|
28
|
+
async function waitForServer(url: string, timeoutMs = 15000) {
|
|
29
|
+
const start = Date.now();
|
|
30
|
+
while (Date.now() - start < timeoutMs) {
|
|
31
|
+
try {
|
|
32
|
+
const r = await fetch(url);
|
|
33
|
+
if (r.ok) return;
|
|
34
|
+
} catch {}
|
|
35
|
+
await Bun.sleep(50);
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`Server did not start within ${timeoutMs}ms`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
42
|
+
try {
|
|
43
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
process.env.PAGE_SESSION_SECRET = PAGE_SECRET;
|
|
48
|
+
|
|
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",
|
|
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: "AuthedPageOwner",
|
|
77
|
+
isLead: false,
|
|
78
|
+
description: "Owner of the authed test page",
|
|
79
|
+
role: "worker",
|
|
80
|
+
capabilities: ["core"],
|
|
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
|
+
describe("/@swarm/api/* proxy — authed-mode page", () => {
|
|
105
|
+
test("authed page: launch → cookie → proxy /agents/:id resolves to page owner", async () => {
|
|
106
|
+
// Create an authed HTML page owned by `agentId`.
|
|
107
|
+
const createRes = await fetch(`${BASE}/api/pages`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
112
|
+
"X-Agent-ID": agentId,
|
|
113
|
+
},
|
|
114
|
+
body: JSON.stringify({
|
|
115
|
+
slug: `authed-${randomUUID().slice(0, 8)}`,
|
|
116
|
+
title: "Authed Proxy Test",
|
|
117
|
+
contentType: "text/html",
|
|
118
|
+
authMode: "authed",
|
|
119
|
+
body: "<h1>authed</h1>",
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
expect(createRes.status).toBe(201);
|
|
123
|
+
const { id } = (await createRes.json()) as { id: string };
|
|
124
|
+
|
|
125
|
+
// Launch.
|
|
126
|
+
const launch = await fetch(`${BASE}/api/pages/${id}/launch`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { Authorization: `Bearer ${API_KEY}`, "X-Agent-ID": agentId },
|
|
129
|
+
});
|
|
130
|
+
expect(launch.status).toBe(204);
|
|
131
|
+
const setCookie = launch.headers.get("set-cookie");
|
|
132
|
+
expect(setCookie).toBeTruthy();
|
|
133
|
+
const cookieValue = /page_session=([^;]+)/.exec(setCookie!)?.[1];
|
|
134
|
+
expect(cookieValue).toBeTruthy();
|
|
135
|
+
|
|
136
|
+
// Drive the proxy with the cookie — should resolve to the page owner's
|
|
137
|
+
// /agents/:id record (not a 401, not a different identity).
|
|
138
|
+
const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
|
|
139
|
+
headers: { Cookie: `page_session=${cookieValue}` },
|
|
140
|
+
});
|
|
141
|
+
expect(res.status).toBe(200);
|
|
142
|
+
const agent = (await res.json()) as { id: string; name: string };
|
|
143
|
+
expect(agent.id).toBe(agentId);
|
|
144
|
+
expect(agent.name).toBe("AuthedPageOwner");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the page-session cookie flow:
|
|
3
|
+
* 1. Create a page (bearer-auth) → POST /api/pages
|
|
4
|
+
* 2. Launch it → POST /api/pages/:id/launch → captures Set-Cookie
|
|
5
|
+
* 3. Hit /@swarm/api/me with the cookie → server-side bearer is injected,
|
|
6
|
+
* X-Agent-ID is rewritten to the page owner's id → 200 with /me payload.
|
|
7
|
+
*
|
|
8
|
+
* Spawns the real `src/http.ts` server with API_KEY set so we exercise the
|
|
9
|
+
* full bearer + cookie + proxy chain, not the in-process handler in
|
|
10
|
+
* isolation.
|
|
11
|
+
*/
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { unlink } from "node:fs/promises";
|
|
15
|
+
import type { Subprocess } from "bun";
|
|
16
|
+
import { signPageSession } from "../utils/page-session";
|
|
17
|
+
|
|
18
|
+
const TEST_PORT = 19877;
|
|
19
|
+
const TEST_DB_PATH = `/tmp/test-page-proxy-${Date.now()}.sqlite`;
|
|
20
|
+
const BASE = `http://localhost:${TEST_PORT}`;
|
|
21
|
+
const API_KEY = "test-page-proxy-key-12345";
|
|
22
|
+
const PAGE_SECRET = "test-page-proxy-page-secret-67890";
|
|
23
|
+
|
|
24
|
+
let serverProc: Subprocess;
|
|
25
|
+
const agentId = randomUUID();
|
|
26
|
+
|
|
27
|
+
async function waitForServer(url: string, timeoutMs = 15000) {
|
|
28
|
+
const start = Date.now();
|
|
29
|
+
while (Date.now() - start < timeoutMs) {
|
|
30
|
+
try {
|
|
31
|
+
const r = await fetch(url);
|
|
32
|
+
if (r.ok) return;
|
|
33
|
+
} catch {}
|
|
34
|
+
await Bun.sleep(50);
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`Server did not start within ${timeoutMs}ms`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
41
|
+
try {
|
|
42
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Match the spawned server's signing secret so cookies we hand-craft via
|
|
47
|
+
// signPageSession() in-process validate at the proxy.
|
|
48
|
+
process.env.PAGE_SESSION_SECRET = PAGE_SECRET;
|
|
49
|
+
|
|
50
|
+
serverProc = Bun.spawn(["bun", "src/http.ts"], {
|
|
51
|
+
cwd: `${import.meta.dir}/../..`,
|
|
52
|
+
env: {
|
|
53
|
+
...process.env,
|
|
54
|
+
PORT: String(TEST_PORT),
|
|
55
|
+
DATABASE_PATH: TEST_DB_PATH,
|
|
56
|
+
API_KEY,
|
|
57
|
+
PAGE_SESSION_SECRET: PAGE_SECRET,
|
|
58
|
+
// Pin the upstream URL the proxy forwards to. Even though the proxy now
|
|
59
|
+
// talks to 127.0.0.1:$PORT directly (not deriveApiBaseUrl), strip any
|
|
60
|
+
// ambient ngrok/external MCP_BASE_URL to keep the test env minimal.
|
|
61
|
+
MCP_BASE_URL: `http://127.0.0.1:${TEST_PORT}`,
|
|
62
|
+
CAPABILITIES: "core,task-pool,messaging,profiles,services,scheduling,memory",
|
|
63
|
+
SLACK_BOT_TOKEN: "",
|
|
64
|
+
GITHUB_WEBHOOK_SECRET: "",
|
|
65
|
+
AGENTMAIL_API_KEY: "",
|
|
66
|
+
},
|
|
67
|
+
stdout: "ignore",
|
|
68
|
+
stderr: "ignore",
|
|
69
|
+
});
|
|
70
|
+
await waitForServer(`${BASE}/health`);
|
|
71
|
+
|
|
72
|
+
// Register the page-owner agent (so /me succeeds after the proxy rewrites
|
|
73
|
+
// X-Agent-ID to this id).
|
|
74
|
+
const reg = await fetch(`${BASE}/api/agents`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
79
|
+
"X-Agent-ID": agentId,
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
name: "PageOwner",
|
|
83
|
+
isLead: false,
|
|
84
|
+
description: "Owner of the test page",
|
|
85
|
+
role: "worker",
|
|
86
|
+
capabilities: ["core"],
|
|
87
|
+
maxTasks: 1,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
if (reg.status !== 201 && reg.status !== 200) {
|
|
91
|
+
throw new Error(`Failed to register agent: ${reg.status} ${await reg.text()}`);
|
|
92
|
+
}
|
|
93
|
+
}, 20000);
|
|
94
|
+
|
|
95
|
+
afterAll(async () => {
|
|
96
|
+
if (serverProc) {
|
|
97
|
+
serverProc.kill();
|
|
98
|
+
try {
|
|
99
|
+
await serverProc.exited;
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
await Bun.sleep(50);
|
|
103
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
104
|
+
try {
|
|
105
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** Helper: create a page owned by `agentId` and return its id. */
|
|
111
|
+
async function createPage(): Promise<string> {
|
|
112
|
+
const res = await fetch(`${BASE}/api/pages`, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: {
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
117
|
+
"X-Agent-ID": agentId,
|
|
118
|
+
},
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
slug: `t-${randomUUID().slice(0, 8)}`,
|
|
121
|
+
title: "Proxy Test",
|
|
122
|
+
contentType: "text/html",
|
|
123
|
+
authMode: "public",
|
|
124
|
+
body: "<h1>proxy test</h1>",
|
|
125
|
+
}),
|
|
126
|
+
});
|
|
127
|
+
expect(res.status).toBe(201);
|
|
128
|
+
const json = (await res.json()) as { id: string };
|
|
129
|
+
return json.id;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe("/api/pages/:id/launch", () => {
|
|
133
|
+
test("issues HttpOnly Set-Cookie + 204", async () => {
|
|
134
|
+
const id = await createPage();
|
|
135
|
+
const res = await fetch(`${BASE}/api/pages/${id}/launch`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { Authorization: `Bearer ${API_KEY}`, "X-Agent-ID": agentId },
|
|
138
|
+
});
|
|
139
|
+
expect(res.status).toBe(204);
|
|
140
|
+
const cookie = res.headers.get("set-cookie");
|
|
141
|
+
expect(cookie).toBeTruthy();
|
|
142
|
+
expect(cookie!).toContain("page_session=");
|
|
143
|
+
expect(cookie!).toContain("HttpOnly");
|
|
144
|
+
expect(cookie!).toContain("Path=/");
|
|
145
|
+
expect(cookie!).toContain("Max-Age=3600");
|
|
146
|
+
// In dev (NODE_ENV != production) the cookie should be SameSite=Lax sans Secure.
|
|
147
|
+
expect(cookie!).toContain("SameSite=Lax");
|
|
148
|
+
expect(cookie!).not.toMatch(/\bSecure\b/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("404 for unknown page id", async () => {
|
|
152
|
+
const res = await fetch(`${BASE}/api/pages/${"0".repeat(32)}/launch`, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { Authorization: `Bearer ${API_KEY}`, "X-Agent-ID": agentId },
|
|
155
|
+
});
|
|
156
|
+
expect(res.status).toBe(404);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("401 without bearer", async () => {
|
|
160
|
+
const id = await createPage();
|
|
161
|
+
const res = await fetch(`${BASE}/api/pages/${id}/launch`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
});
|
|
164
|
+
expect(res.status).toBe(401);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("OPTIONS preflight returns 204 with CORS headers when Origin set", async () => {
|
|
168
|
+
const id = await createPage();
|
|
169
|
+
const res = await fetch(`${BASE}/api/pages/${id}/launch`, {
|
|
170
|
+
method: "OPTIONS",
|
|
171
|
+
headers: { Origin: "http://localhost:5274" },
|
|
172
|
+
});
|
|
173
|
+
// /core's OPTIONS handler returns 204 first — but our route-specific
|
|
174
|
+
// OPTIONS handler in handlePages sets CORS headers. Either way the
|
|
175
|
+
// browser sees 204; verify the response is 204.
|
|
176
|
+
expect(res.status).toBe(204);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("/@swarm/api/* proxy", () => {
|
|
181
|
+
// The proxy rewrites `/@swarm/api/<rest>` → `/api/<rest>`. We use
|
|
182
|
+
// `/api/agents/<id>` as the canonical exerciser since it requires both
|
|
183
|
+
// bearer auth AND a valid agent id — proving the proxy injected both.
|
|
184
|
+
test("forwards GET /@swarm/api/agents/:id with cookie → 200 carrying page-owner agent", async () => {
|
|
185
|
+
const id = await createPage();
|
|
186
|
+
const launch = await fetch(`${BASE}/api/pages/${id}/launch`, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { Authorization: `Bearer ${API_KEY}`, "X-Agent-ID": agentId },
|
|
189
|
+
});
|
|
190
|
+
expect(launch.status).toBe(204);
|
|
191
|
+
const setCookie = launch.headers.get("set-cookie");
|
|
192
|
+
expect(setCookie).toBeTruthy();
|
|
193
|
+
|
|
194
|
+
const cookieValue = /page_session=([^;]+)/.exec(setCookie!)?.[1];
|
|
195
|
+
expect(cookieValue).toBeTruthy();
|
|
196
|
+
|
|
197
|
+
const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
|
|
198
|
+
headers: { Cookie: `page_session=${cookieValue}` },
|
|
199
|
+
});
|
|
200
|
+
expect(res.status).toBe(200);
|
|
201
|
+
const agent = (await res.json()) as { id: string; name: string };
|
|
202
|
+
expect(agent.id).toBe(agentId);
|
|
203
|
+
expect(agent.name).toBe("PageOwner");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("rejects request without cookie → 401", async () => {
|
|
207
|
+
const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`);
|
|
208
|
+
expect(res.status).toBe(401);
|
|
209
|
+
const body = (await res.json()) as { error: string };
|
|
210
|
+
expect(body.error).toBe("no page session");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("rejects expired cookie → 401", async () => {
|
|
214
|
+
const expired = await signPageSession({
|
|
215
|
+
pageId: "deadbeef".repeat(4),
|
|
216
|
+
exp: Math.floor(Date.now() / 1000) - 60,
|
|
217
|
+
});
|
|
218
|
+
const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
|
|
219
|
+
headers: { Cookie: `page_session=${expired}` },
|
|
220
|
+
});
|
|
221
|
+
expect(res.status).toBe(401);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("rejects tampered signature → 401", async () => {
|
|
225
|
+
const id = await createPage();
|
|
226
|
+
const exp = Math.floor(Date.now() / 1000) + 3600;
|
|
227
|
+
const good = await signPageSession({ pageId: id, exp });
|
|
228
|
+
const [head, sig] = good.split(".");
|
|
229
|
+
const tamperedSig = `${sig!.slice(0, -1)}${sig!.slice(-1) === "A" ? "B" : "A"}`;
|
|
230
|
+
const bad = `${head}.${tamperedSig}`;
|
|
231
|
+
const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
|
|
232
|
+
headers: { Cookie: `page_session=${bad}` },
|
|
233
|
+
});
|
|
234
|
+
expect(res.status).toBe(401);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("rejects cookie for deleted page → 401", async () => {
|
|
238
|
+
// Sign a cookie referencing a never-existed page id. verifyPageSession
|
|
239
|
+
// returns the payload, getPage returns null → 401 "page session no
|
|
240
|
+
// longer valid". (Step-3 will ship DELETE; this test just exercises the
|
|
241
|
+
// proxy's missing-page branch without depending on it.)
|
|
242
|
+
const ghost = await signPageSession({
|
|
243
|
+
pageId: "fade".repeat(8),
|
|
244
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
245
|
+
});
|
|
246
|
+
const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
|
|
247
|
+
headers: { Cookie: `page_session=${ghost}` },
|
|
248
|
+
});
|
|
249
|
+
expect(res.status).toBe(401);
|
|
250
|
+
const body = (await res.json()) as { error: string };
|
|
251
|
+
expect(body.error).toBe("page session no longer valid");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("proxy does NOT require a bearer header (cookie is the auth)", async () => {
|
|
255
|
+
const id = await createPage();
|
|
256
|
+
const exp = Math.floor(Date.now() / 1000) + 3600;
|
|
257
|
+
const token = await signPageSession({ pageId: id, exp });
|
|
258
|
+
// Send WITHOUT Authorization header — pure cookie auth.
|
|
259
|
+
const res = await fetch(`${BASE}/@swarm/api/agents/${agentId}`, {
|
|
260
|
+
headers: { Cookie: `page_session=${token}` },
|
|
261
|
+
});
|
|
262
|
+
expect(res.status).toBe(200);
|
|
263
|
+
const agent = (await res.json()) as { id: string };
|
|
264
|
+
expect(agent.id).toBe(agentId);
|
|
265
|
+
});
|
|
266
|
+
});
|