@desplega.ai/agent-swarm 1.78.0 → 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/error-tracker.test.ts +30 -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/error-tracker.ts +55 -1
- package/src/utils/page-session.ts +254 -0
- 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("
|
|
68
|
-
const
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
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
|
|
80
|
-
expect(BROWSER_SDK_JS).toContain(
|
|
77
|
+
for (const domain of expectedDomains) {
|
|
78
|
+
expect(BROWSER_SDK_JS).toContain(domain);
|
|
81
79
|
}
|
|
82
80
|
});
|
|
83
81
|
|
|
84
|
-
test("
|
|
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("
|
|
89
|
-
expect(BROWSER_SDK_JS).toContain("/@swarm/api
|
|
90
|
-
|
|
91
|
-
expect(BROWSER_SDK_JS).toContain("
|
|
92
|
-
expect(BROWSER_SDK_JS).toContain("
|
|
93
|
-
expect(BROWSER_SDK_JS).toContain("
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
+
});
|