@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.
Files changed (46) hide show
  1. package/openapi.json +542 -1
  2. package/package.json +1 -1
  3. package/plugin/skills/artifacts/SKILL.md +151 -0
  4. package/plugin/skills/artifacts/examples/static-report.sh +1 -1
  5. package/plugin/skills/pages/SKILL.md +274 -0
  6. package/src/artifact-sdk/browser-sdk.ts +105 -20
  7. package/src/be/db.ts +239 -0
  8. package/src/be/migrations/059_pages.sql +34 -0
  9. package/src/be/migrations/060_page_versions.sql +19 -0
  10. package/src/commands/artifact.ts +17 -11
  11. package/src/http/index.ts +7 -1
  12. package/src/http/page-proxy.ts +208 -0
  13. package/src/http/pages-public.ts +466 -0
  14. package/src/http/pages.ts +608 -0
  15. package/src/http/utils.ts +68 -5
  16. package/src/pages/version.ts +44 -0
  17. package/src/prompts/session-templates.ts +51 -0
  18. package/src/server.ts +10 -1
  19. package/src/tests/artifact-commands.test.ts +92 -0
  20. package/src/tests/artifact-sdk.test.ts +80 -74
  21. package/src/tests/create-page-tool.test.ts +197 -0
  22. package/src/tests/fixtures/sample-json-page.json +52 -0
  23. package/src/tests/launch-password-rejection.test.ts +139 -0
  24. package/src/tests/page-proxy-authed.test.ts +146 -0
  25. package/src/tests/page-proxy.test.ts +266 -0
  26. package/src/tests/page-session.test.ts +164 -0
  27. package/src/tests/pages-actions-endpoint.test.ts +102 -0
  28. package/src/tests/pages-authed-mode.test.ts +207 -0
  29. package/src/tests/pages-http.test.ts +193 -0
  30. package/src/tests/pages-list-endpoint.test.ts +149 -0
  31. package/src/tests/pages-password-hash.test.ts +57 -0
  32. package/src/tests/pages-password-mode.test.ts +265 -0
  33. package/src/tests/pages-public-authed-401.test.ts +102 -0
  34. package/src/tests/pages-public-html.test.ts +151 -0
  35. package/src/tests/pages-public-json-redirect.test.ts +86 -0
  36. package/src/tests/pages-storage.test.ts +196 -0
  37. package/src/tests/pages-versioning.test.ts +231 -0
  38. package/src/tests/prompt-template-session.test.ts +3 -2
  39. package/src/tests/skill-update-scope.test.ts +165 -0
  40. package/src/tests/workflow-wait-event.test.ts +4 -7
  41. package/src/tools/create-page.ts +263 -0
  42. package/src/tools/skills/skill-update.ts +26 -0
  43. package/src/tools/tool-config.ts +3 -0
  44. package/src/types.ts +54 -0
  45. package/src/utils/page-session.ts +254 -0
  46. package/plugin/skills/artifacts/skill.md +0 -70
@@ -0,0 +1,44 @@
1
+ import { createPageVersion, getPage, getPageVersions } from "../be/db";
2
+ import type { PageSnapshot, PageVersion } from "../types";
3
+
4
+ /**
5
+ * Create a version snapshot of a page's current state.
6
+ *
7
+ * Call this BEFORE applying an update to preserve the pre-update state.
8
+ * Mirrors `snapshotWorkflow` (src/workflows/version.ts:13-44).
9
+ *
10
+ * 1. Load current page state
11
+ * 2. Get max version number for this page (page_versions ORDER BY version DESC)
12
+ * 3. Insert page_versions row with version+1 and the pre-update snapshot
13
+ *
14
+ * Throws on missing parent. Callers in HTTP handlers wrap this in a try/catch
15
+ * with an empty catch — snapshot failure should not block the update (matches
16
+ * the workflow pattern at src/http/workflows.ts:483-486).
17
+ */
18
+ export function snapshotPage(pageId: string, changedByAgentId?: string): PageVersion {
19
+ const page = getPage(pageId);
20
+ if (!page) {
21
+ throw new Error(`Page ${pageId} not found — cannot create snapshot`);
22
+ }
23
+
24
+ const existingVersions = getPageVersions(pageId);
25
+ const maxVersion = existingVersions.length > 0 ? existingVersions[0]!.version : 0;
26
+ const nextVersion = maxVersion + 1;
27
+
28
+ const snapshot: PageSnapshot = {
29
+ title: page.title,
30
+ description: page.description,
31
+ contentType: page.contentType,
32
+ authMode: page.authMode,
33
+ passwordHash: page.passwordHash,
34
+ body: page.body,
35
+ needsCredentials: page.needsCredentials,
36
+ };
37
+
38
+ return createPageVersion({
39
+ pageId,
40
+ version: nextVersion,
41
+ snapshot,
42
+ changedByAgentId,
43
+ });
44
+ }
@@ -297,6 +297,20 @@ agent-fs comment add docs/spec.md --body "Needs clarification on auth flow"
297
297
  agent-fs comment list docs/spec.md
298
298
  \`\`\`
299
299
 
300
+ ### Sharing agent-fs files with humans
301
+
302
+ To give a human a direct link to a file, build the URL from the live host
303
+ (env-var driven, never hardcode):
304
+
305
+ \`\`\`
306
+ \${AGENT_FS_LIVE_URL}/file/~/<org_id>/<drive_id>/<file_path>
307
+ \`\`\`
308
+
309
+ \`AGENT_FS_LIVE_URL\` defaults to \`https://live.agent-fs.dev\` if not set.
310
+ \`<org_id>\` and \`<drive_id>\` come from the file's \`agent-fs stat <path> --json\`
311
+ output (or the agent-fs CLI returns them on write). Use the **shared** drive
312
+ id for files humans should review.
313
+
300
314
  Key conventions:
301
315
  - **Personal drive**: thoughts/{type}/YYYY-MM-DD-topic.md (plans, research, brainstorms)
302
316
  - **Shared drive**: thoughts/{{agentId}}/{type}/YYYY-MM-DD-topic.md (same structure, namespaced by your ID)
@@ -442,6 +456,41 @@ registerTemplate({
442
456
  Agents can serve interactive web content (HTML pages, dashboards, approval flows) via public URLs using localtunnel.
443
457
  Use the \`/artifacts\` skill for detailed instructions, examples, and API reference.
444
458
  Artifact content should be stored in \`/workspace/personal/artifacts/\` (persisted across sessions).
459
+
460
+ For lightweight static reports / dashboards (HTML or JSON), prefer the
461
+ \`create_page\` MCP tool (\`pages\` skill) — it stores in SQLite and serves at
462
+ \`/p/:id\` without a PM2 process or tunnel.
463
+ `,
464
+ variables: [],
465
+ category: "system",
466
+ });
467
+
468
+ registerTemplate({
469
+ eventType: "system.agent.share_urls",
470
+ header: "",
471
+ defaultBody: `
472
+ ### Share URLs (read from env, never hardcode)
473
+
474
+ When you emit a link meant for a human or another agent — a page, artifact,
475
+ agent-fs file, or any external URL — read the host from env. Hardcoded hosts
476
+ break across deployments.
477
+
478
+ | Env var | Purpose | Prod example |
479
+ |---|---|---|
480
+ | \`MCP_BASE_URL\` | API origin. Use for \`/p/:id\` direct page links and any \`/api/*\` curl examples. | \`https://api.example-swarm.dev\` |
481
+ | \`APP_URL\` | SPA origin. Default share target for pages: \`\${APP_URL}/pages/:id\`. Append \`?mode=full\` for a maximized view (slim header + body fills viewport). | \`https://app.example-swarm.dev\` |
482
+ | \`SWARM_URL\` | Bare host (no scheme). Use in copy / Slack messages that need just the domain. | \`app.example-swarm.dev\` |
483
+ | \`AGENT_FS_LIVE_URL\` | agent-fs live origin. Share files via \`\${AGENT_FS_LIVE_URL}/file/~/<org_id>/<drive_id>/<file_path>\`. Defaults to \`https://live.agent-fs.dev\` if unset. | \`https://live.agent-fs.dev\` |
484
+
485
+ **Page share patterns** (most common):
486
+ - Default: \`\${APP_URL}/pages/:id\` — opens the SPA with chrome.
487
+ - Full / standalone: \`\${APP_URL}/pages/:id?mode=full\` — hides sidebar/header; slim row with title + Exit-Full.
488
+ - Direct API (no SPA): \`\${MCP_BASE_URL}/p/:id\` — HTML inlines; JSON 302→SPA.
489
+
490
+ **agent-fs share pattern**: \`\${AGENT_FS_LIVE_URL}/file/~/<org_id>/<drive_id>/<file_path>\`.
491
+
492
+ If a required env var is missing, **surface that to the user** — never fall
493
+ back to a localhost value or invent a host when shipping a share link.
445
494
  `,
446
495
  variables: [],
447
496
  category: "system",
@@ -493,6 +542,7 @@ registerTemplate({
493
542
  {{@template[system.agent.context_mode]}}
494
543
 
495
544
  {{@template[system.agent.system]}}
545
+ {{@template[system.agent.share_urls]}}
496
546
  {{@template[system.agent.code_quality]}}`,
497
547
  variables: [
498
548
  { name: "role", description: "The agent's role" },
@@ -513,6 +563,7 @@ registerTemplate({
513
563
  {{@template[system.agent.context_mode]}}
514
564
 
515
565
  {{@template[system.agent.system]}}
566
+ {{@template[system.agent.share_urls]}}
516
567
  {{@template[system.agent.code_quality]}}`,
517
568
  variables: [
518
569
  { name: "role", description: "The agent's role" },
package/src/server.ts CHANGED
@@ -5,6 +5,7 @@ import { registerCancelTaskTool } from "./tools/cancel-task";
5
5
  import { registerContextDiffTool } from "./tools/context-diff";
6
6
  import { registerContextHistoryTool } from "./tools/context-history";
7
7
  import { registerCreateChannelTool } from "./tools/create-channel";
8
+ import { registerCreatePageTool } from "./tools/create-page";
8
9
  import { registerDbQueryTool } from "./tools/db-query";
9
10
  import { registerDeleteChannelTool } from "./tools/delete-channel";
10
11
  import { registerGetSwarmTool } from "./tools/get-swarm";
@@ -120,7 +121,7 @@ import {
120
121
 
121
122
  // Capability-based feature flags
122
123
  // Default: all capabilities enabled
123
- const DEFAULT_CAPABILITIES = "core,task-pool,profiles,services,scheduling,memory,workflows";
124
+ const DEFAULT_CAPABILITIES = "core,task-pool,profiles,services,scheduling,memory,workflows,pages";
124
125
  const CAPABILITIES = new Set(
125
126
  (process.env.CAPABILITIES || DEFAULT_CAPABILITIES).split(",").map((s) => s.trim()),
126
127
  );
@@ -284,6 +285,14 @@ export function createServer() {
284
285
  registerSkillSyncRemoteTool(server);
285
286
  registerSkillPublishTool(server);
286
287
 
288
+ // Pages capability - DB-backed lightweight artifacts (HTML / JSON specs).
289
+ // Enabled by default (added to DEFAULT_CAPABILITIES in step-9 of the
290
+ // db-backed-pages plan). Operators can disable via explicit
291
+ // `CAPABILITIES=...` env without `pages`.
292
+ if (hasCapability("pages")) {
293
+ registerCreatePageTool(server);
294
+ }
295
+
287
296
  // MCP Servers - always registered
288
297
  registerMcpServerCreateTool(server);
289
298
  registerMcpServerUpdateTool(server);
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Regression test for the `/api/services` response-shape bug in
3
+ * `src/commands/artifact.ts` (artifactList / artifactStop).
4
+ *
5
+ * The endpoint returns `{ services: [...] }`, but earlier the code
6
+ * cast the JSON as a bare `Array<...>` and crashed with
7
+ * `services.filter is not a function`.
8
+ */
9
+
10
+ import { afterAll, afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
11
+ import { runArtifact } from "../commands/artifact";
12
+
13
+ const originalFetch = globalThis.fetch;
14
+ const originalLog = console.log;
15
+ const originalError = console.error;
16
+
17
+ let capturedOut: string[] = [];
18
+ let capturedErr: string[] = [];
19
+
20
+ beforeAll(() => {
21
+ console.log = (...args: unknown[]) => {
22
+ capturedOut.push(args.map(String).join(" "));
23
+ };
24
+ console.error = (...args: unknown[]) => {
25
+ capturedErr.push(args.map(String).join(" "));
26
+ };
27
+ });
28
+
29
+ afterEach(() => {
30
+ globalThis.fetch = originalFetch;
31
+ capturedOut = [];
32
+ capturedErr = [];
33
+ });
34
+
35
+ afterAll(() => {
36
+ globalThis.fetch = originalFetch;
37
+ console.log = originalLog;
38
+ console.error = originalError;
39
+ });
40
+
41
+ function jsonResponse(body: unknown, status = 200): Response {
42
+ return new Response(JSON.stringify(body), {
43
+ status,
44
+ headers: { "Content-Type": "application/json" },
45
+ });
46
+ }
47
+
48
+ describe("runArtifact('list')", () => {
49
+ test("handles wrapped { services: [] } without throwing", async () => {
50
+ globalThis.fetch = mock(() => Promise.resolve(jsonResponse({ services: [] })));
51
+
52
+ await expect(runArtifact("list", { additionalArgs: [] })).resolves.toBeUndefined();
53
+ expect(capturedOut.join("\n")).toContain("No active artifacts");
54
+ expect(capturedErr.join("\n")).not.toContain("services.filter");
55
+ });
56
+
57
+ test("renders artifact rows from { services: [...] }", async () => {
58
+ globalThis.fetch = mock(() =>
59
+ Promise.resolve(
60
+ jsonResponse({
61
+ services: [
62
+ {
63
+ name: "artifact-testart",
64
+ agentId: "agent-xyz",
65
+ status: "healthy",
66
+ metadata: {
67
+ type: "artifact",
68
+ artifactName: "testart",
69
+ port: 4242,
70
+ publicUrl: "https://testart.loca.lt",
71
+ },
72
+ },
73
+ ],
74
+ }),
75
+ ),
76
+ );
77
+
78
+ await expect(runArtifact("list", { additionalArgs: [] })).resolves.toBeUndefined();
79
+ const out = capturedOut.join("\n");
80
+ expect(out).toContain("testart");
81
+ expect(out).toContain("4242");
82
+ expect(out).toContain("https://testart.loca.lt");
83
+ expect(out).toContain("healthy");
84
+ });
85
+
86
+ test("falls back to [] when response omits services key", async () => {
87
+ globalThis.fetch = mock(() => Promise.resolve(jsonResponse({})));
88
+
89
+ await expect(runArtifact("list", { additionalArgs: [] })).resolves.toBeUndefined();
90
+ expect(capturedOut.join("\n")).toContain("No active artifacts");
91
+ });
92
+ });
@@ -64,48 +64,48 @@ describe("BROWSER_SDK_JS", () => {
64
64
  expect(BROWSER_SDK_JS).toContain("class SwarmSDK");
65
65
  });
66
66
 
67
- test("contains all expected API methods", () => {
68
- const expectedMethods = [
69
- "createTask",
70
- "getTasks",
71
- "getTaskDetails",
72
- "storeProgress",
73
- "postMessage",
74
- "readMessages",
75
- "getSwarm",
76
- "listServices",
77
- "slackReply",
67
+ test("exposes the seven canonical domains", () => {
68
+ const expectedDomains = [
69
+ "this.tasks",
70
+ "this.agents",
71
+ "this.events",
72
+ "this.memory",
73
+ "this.repos",
74
+ "this.schedules",
75
+ "this.approvalRequests",
78
76
  ];
79
- for (const method of expectedMethods) {
80
- expect(BROWSER_SDK_JS).toContain(method);
77
+ for (const domain of expectedDomains) {
78
+ expect(BROWSER_SDK_JS).toContain(domain);
81
79
  }
82
80
  });
83
81
 
84
- test("assigns SwarmSDK to window", () => {
82
+ test("removed domains are NOT exposed (messages, services, slack)", () => {
83
+ const removed = ["this.messages", "this.services", "this.slack", "postMessage", "readMessages"];
84
+ for (const r of removed) {
85
+ expect(BROWSER_SDK_JS).not.toContain(r);
86
+ }
87
+ });
88
+
89
+ test("assigns SwarmSDK class + swarmSdk singleton to window", () => {
85
90
  expect(BROWSER_SDK_JS).toContain("window.SwarmSDK = SwarmSDK");
91
+ expect(BROWSER_SDK_JS).toContain("window.swarmSdk = new SwarmSDK()");
86
92
  });
87
93
 
88
- test("uses correct proxy API paths", () => {
89
- expect(BROWSER_SDK_JS).toContain("/@swarm/api/tasks");
90
- expect(BROWSER_SDK_JS).toContain("/@swarm/api/agents");
91
- expect(BROWSER_SDK_JS).toContain("/@swarm/api/messages");
92
- expect(BROWSER_SDK_JS).toContain("/@swarm/api/services");
93
- expect(BROWSER_SDK_JS).toContain("/@swarm/api/slack/reply");
94
+ test("routes calls through the /@swarm/api/* proxy", () => {
95
+ expect(BROWSER_SDK_JS).toContain("const base = '/@swarm/api'");
96
+ // Sentinel endpoints — one per domain.
97
+ expect(BROWSER_SDK_JS).toContain("'/tasks'");
98
+ expect(BROWSER_SDK_JS).toContain("'/agents'");
99
+ expect(BROWSER_SDK_JS).toContain("'/events'");
100
+ expect(BROWSER_SDK_JS).toContain("'/memory/search'");
101
+ expect(BROWSER_SDK_JS).toContain("'/repos'");
102
+ expect(BROWSER_SDK_JS).toContain("'/schedules'");
103
+ expect(BROWSER_SDK_JS).toContain("'/approval-requests'");
94
104
  });
95
105
 
96
106
  test("fetches config on construction", () => {
97
107
  expect(BROWSER_SDK_JS).toContain("fetch('/@swarm/config')");
98
108
  });
99
-
100
- test("has _post helper with JSON content-type", () => {
101
- expect(BROWSER_SDK_JS).toContain("_post(url, body)");
102
- expect(BROWSER_SDK_JS).toContain("'Content-Type': 'application/json'");
103
- expect(BROWSER_SDK_JS).toContain("JSON.stringify(body)");
104
- });
105
-
106
- test("has _get helper", () => {
107
- expect(BROWSER_SDK_JS).toContain("_get(url)");
108
- });
109
109
  });
110
110
 
111
111
  // ─── createArtifactServer factory tests ──────────────────────────────────
@@ -536,27 +536,29 @@ describe("artifact CLI command", () => {
536
536
  const url = new URL(req.url);
537
537
  if (url.pathname === "/api/services") {
538
538
  return new Response(
539
- JSON.stringify([
540
- {
541
- id: "svc-1",
542
- name: "artifact-dashboard",
543
- agentId: "agent-123",
544
- status: "healthy",
545
- metadata: {
546
- type: "artifact",
547
- artifactName: "dashboard",
548
- port: 3001,
549
- publicUrl: "https://test.lt.example.com",
539
+ JSON.stringify({
540
+ services: [
541
+ {
542
+ id: "svc-1",
543
+ name: "artifact-dashboard",
544
+ agentId: "agent-123",
545
+ status: "healthy",
546
+ metadata: {
547
+ type: "artifact",
548
+ artifactName: "dashboard",
549
+ port: 3001,
550
+ publicUrl: "https://test.lt.example.com",
551
+ },
550
552
  },
551
- },
552
- {
553
- id: "svc-2",
554
- name: "some-other-service",
555
- agentId: "agent-456",
556
- status: "healthy",
557
- metadata: { type: "web" },
558
- },
559
- ]),
553
+ {
554
+ id: "svc-2",
555
+ name: "some-other-service",
556
+ agentId: "agent-456",
557
+ status: "healthy",
558
+ metadata: { type: "web" },
559
+ },
560
+ ],
561
+ }),
560
562
  );
561
563
  }
562
564
  return new Response("Not found", { status: 404 });
@@ -593,7 +595,7 @@ describe("artifact CLI command", () => {
593
595
  const mockPort = await getAvailablePort();
594
596
  const mockServer = Bun.serve({
595
597
  port: mockPort,
596
- fetch: () => new Response(JSON.stringify([])),
598
+ fetch: () => new Response(JSON.stringify({ services: [] })),
597
599
  });
598
600
 
599
601
  const origEnv = { ...process.env };
@@ -626,22 +628,24 @@ describe("artifact CLI command", () => {
626
628
  port: mockPort,
627
629
  fetch: () =>
628
630
  new Response(
629
- JSON.stringify([
630
- {
631
- id: "s1",
632
- name: "web-server",
633
- agentId: "a1",
634
- status: "healthy",
635
- metadata: { type: "web" },
636
- },
637
- {
638
- id: "s2",
639
- name: "api-server",
640
- agentId: "a2",
641
- status: "healthy",
642
- metadata: {},
643
- },
644
- ]),
631
+ JSON.stringify({
632
+ services: [
633
+ {
634
+ id: "s1",
635
+ name: "web-server",
636
+ agentId: "a1",
637
+ status: "healthy",
638
+ metadata: { type: "web" },
639
+ },
640
+ {
641
+ id: "s2",
642
+ name: "api-server",
643
+ agentId: "a2",
644
+ status: "healthy",
645
+ metadata: {},
646
+ },
647
+ ],
648
+ }),
645
649
  ),
646
650
  });
647
651
 
@@ -682,13 +686,15 @@ describe("artifact CLI command", () => {
682
686
  const url = new URL(req.url);
683
687
  if (req.method === "GET" && url.pathname === "/api/services") {
684
688
  return new Response(
685
- JSON.stringify([
686
- {
687
- id: "svc-to-delete",
688
- name: "artifact-my-report",
689
- metadata: { type: "artifact", artifactName: "my-report" },
690
- },
691
- ]),
689
+ JSON.stringify({
690
+ services: [
691
+ {
692
+ id: "svc-to-delete",
693
+ name: "artifact-my-report",
694
+ metadata: { type: "artifact", artifactName: "my-report" },
695
+ },
696
+ ],
697
+ }),
692
698
  );
693
699
  }
694
700
  if (req.method === "DELETE" && url.pathname.startsWith("/api/services/")) {
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `create_page` MCP tool — unit-level coverage. Registers the tool against
3
+ * a fresh `McpServer`, pulls the handler out of the SDK's registry, and
4
+ * invokes it directly with a stubbed agent-id `requestInfo`.
5
+ *
6
+ * Verifies:
7
+ * - first-call path: creates a row in `pages`, returns `{id, version=1, app_url, api_url}`
8
+ * - upsert path: second call with the same slug bumps the edit-counter
9
+ * and writes a version row
10
+ * - capability gate: tool is registered when `CAPABILITIES` contains
11
+ * `pages`, NOT registered when missing (verified via `createServer`)
12
+ */
13
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
+ import crypto from "node:crypto";
15
+ import { unlink } from "node:fs/promises";
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { closeDb, getPageBySlug, getPageVersions, initDb } from "../be/db";
18
+ import { registerCreatePageTool } from "../tools/create-page";
19
+
20
+ const TEST_DB_PATH = "./test-create-page-tool.sqlite";
21
+
22
+ type RegisteredTool = {
23
+ handler: (args: unknown, extra: unknown) => Promise<unknown>;
24
+ };
25
+
26
+ function buildServer() {
27
+ const server = new McpServer({ name: "create-page-test", version: "1.0.0" });
28
+ registerCreatePageTool(server);
29
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
30
+ ._registeredTools;
31
+ const tool = registered.create_page;
32
+ if (!tool) throw new Error("create_page tool not registered");
33
+ return tool;
34
+ }
35
+
36
+ describe("create_page MCP tool", () => {
37
+ const agentId = crypto.randomUUID();
38
+ const fakeMeta = {
39
+ sessionId: "session-1",
40
+ requestInfo: { headers: { "x-agent-id": agentId } },
41
+ };
42
+
43
+ beforeAll(async () => {
44
+ for (const suffix of ["", "-wal", "-shm"]) {
45
+ try {
46
+ await unlink(`${TEST_DB_PATH}${suffix}`);
47
+ } catch {}
48
+ }
49
+ initDb(TEST_DB_PATH);
50
+ // The tool reads MCP_BASE_URL / APP_URL when building share URLs.
51
+ process.env.MCP_BASE_URL = "http://test-api:9999";
52
+ process.env.APP_URL = "http://test-app:5274";
53
+ });
54
+
55
+ afterAll(async () => {
56
+ closeDb();
57
+ delete process.env.MCP_BASE_URL;
58
+ delete process.env.APP_URL;
59
+ for (const suffix of ["", "-wal", "-shm"]) {
60
+ try {
61
+ await unlink(`${TEST_DB_PATH}${suffix}`);
62
+ } catch {}
63
+ }
64
+ });
65
+
66
+ test("first call creates a row + returns shareable URLs", async () => {
67
+ const tool = buildServer();
68
+ const result = (await tool.handler(
69
+ {
70
+ title: "Hello Page",
71
+ body: "<h1>hello</h1>",
72
+ contentType: "text/html",
73
+ authMode: "public",
74
+ },
75
+ fakeMeta,
76
+ )) as {
77
+ structuredContent: {
78
+ id: string;
79
+ version: number;
80
+ app_url: string;
81
+ api_url: string;
82
+ yourAgentId: string;
83
+ };
84
+ };
85
+
86
+ expect(result.structuredContent.id).toMatch(/^[0-9a-f]{32}$/);
87
+ expect(result.structuredContent.version).toBe(1);
88
+ expect(result.structuredContent.api_url).toBe(
89
+ `http://test-api:9999/p/${result.structuredContent.id}`,
90
+ );
91
+ expect(result.structuredContent.app_url).toBe(
92
+ `http://test-app:5274/pages/${result.structuredContent.id}`,
93
+ );
94
+ expect(result.structuredContent.yourAgentId).toBe(agentId);
95
+
96
+ // DB row exists with the auto-slug from the title.
97
+ const row = getPageBySlug(agentId, "hello-page");
98
+ expect(row).not.toBeNull();
99
+ expect(row!.body).toBe("<h1>hello</h1>");
100
+ });
101
+
102
+ test("re-running with the same slug upserts + bumps edit-counter", async () => {
103
+ const tool = buildServer();
104
+
105
+ const first = (await tool.handler(
106
+ {
107
+ title: "Upsert Page",
108
+ slug: "upsert",
109
+ body: "v0",
110
+ contentType: "text/html",
111
+ authMode: "public",
112
+ },
113
+ fakeMeta,
114
+ )) as { structuredContent: { id: string; version: number } };
115
+ expect(first.structuredContent.version).toBe(1);
116
+
117
+ const second = (await tool.handler(
118
+ {
119
+ title: "Upsert Page",
120
+ slug: "upsert",
121
+ body: "v1",
122
+ contentType: "text/html",
123
+ authMode: "public",
124
+ },
125
+ fakeMeta,
126
+ )) as { structuredContent: { id: string; version: number } };
127
+ expect(second.structuredContent.id).toBe(first.structuredContent.id);
128
+ expect(second.structuredContent.version).toBe(2);
129
+
130
+ // Version row holds the PRE-update body.
131
+ const versions = getPageVersions(first.structuredContent.id);
132
+ expect(versions).toHaveLength(1);
133
+ expect(versions[0]!.snapshot.body).toBe("v0");
134
+
135
+ // Parent now holds the new body.
136
+ const row = getPageBySlug(agentId, "upsert");
137
+ expect(row?.body).toBe("v1");
138
+ });
139
+
140
+ test("missing X-Agent-ID returns an error result", async () => {
141
+ const tool = buildServer();
142
+ const result = (await tool.handler(
143
+ {
144
+ title: "Anon",
145
+ body: "x",
146
+ contentType: "text/html",
147
+ authMode: "public",
148
+ },
149
+ { sessionId: "s", requestInfo: { headers: {} } },
150
+ )) as { isError?: boolean };
151
+ expect(result.isError).toBe(true);
152
+ });
153
+
154
+ test("password is hashed (not stored verbatim)", async () => {
155
+ const tool = buildServer();
156
+ await tool.handler(
157
+ {
158
+ title: "Pw",
159
+ slug: "pw-tool",
160
+ body: "secret",
161
+ contentType: "text/html",
162
+ authMode: "password",
163
+ password: "open-sesame",
164
+ },
165
+ fakeMeta,
166
+ );
167
+ const row = getPageBySlug(agentId, "pw-tool");
168
+ expect(row?.passwordHash).toBeDefined();
169
+ expect(row?.passwordHash).not.toBe("open-sesame");
170
+ expect(await Bun.password.verify("open-sesame", row!.passwordHash!)).toBe(true);
171
+ });
172
+ });
173
+
174
+ describe("create_page MCP tool capability gating", () => {
175
+ test("not registered without 'pages' capability; registered with it", async () => {
176
+ // Save + clear env then load the server module fresh.
177
+ const orig = process.env.CAPABILITIES;
178
+ try {
179
+ // Default capabilities don't include 'pages' (step-3 enforced).
180
+ process.env.CAPABILITIES = "core,task-pool,profiles,services,scheduling,memory,workflows";
181
+ // Force a fresh module evaluation so the capability check re-runs.
182
+ delete require.cache[require.resolve("../server")];
183
+ const without = await import("../server");
184
+ expect(without.hasCapability("pages")).toBe(false);
185
+
186
+ process.env.CAPABILITIES =
187
+ "core,task-pool,profiles,services,scheduling,memory,workflows,pages";
188
+ delete require.cache[require.resolve("../server")];
189
+ const withPages = await import("../server");
190
+ expect(withPages.hasCapability("pages")).toBe(true);
191
+ } finally {
192
+ if (orig === undefined) delete process.env.CAPABILITIES;
193
+ else process.env.CAPABILITIES = orig;
194
+ delete require.cache[require.resolve("../server")];
195
+ }
196
+ });
197
+ });