@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,254 @@
1
+ /**
2
+ * HMAC-SHA256 signed cookie helper for the page-session cookie.
3
+ *
4
+ * Scope-locked to the `pages` feature (db-backed pages) — do NOT reuse for any
5
+ * other surface. If a second cookie use-case emerges, refactor then.
6
+ *
7
+ * Cookie payload: `{pageId, exp}` where `exp` is a unix seconds timestamp.
8
+ * Wire shape: `${base64url(JSON.stringify(payload))}.${base64url(HMAC-SHA256(payload, secret))}`.
9
+ * Secret resolution: `process.env.PAGE_SESSION_SECRET || process.env.API_KEY`
10
+ * — the API_KEY fallback keeps existing dev setups working without forcing a
11
+ * new env var. Verification is constant-time via `crypto.timingSafeEqual` so
12
+ * we don't leak bits via signature-comparison timing.
13
+ *
14
+ * Both functions are async because `crypto.subtle.sign` is async.
15
+ */
16
+ import { timingSafeEqual } from "node:crypto";
17
+
18
+ export interface PageSessionPayload {
19
+ pageId: string;
20
+ /** Unix seconds (NOT millis). */
21
+ exp: number;
22
+ }
23
+
24
+ /** base64url encode a byte buffer (no padding). */
25
+ function base64urlEncode(buf: ArrayBuffer | Uint8Array): string {
26
+ const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
27
+ // `Buffer` from `node:buffer` is available in Bun globally — encode then
28
+ // translate `+/` → `-_` and strip `=` padding for URL-safety.
29
+ return Buffer.from(u8)
30
+ .toString("base64")
31
+ .replace(/\+/g, "-")
32
+ .replace(/\//g, "_")
33
+ .replace(/=+$/, "");
34
+ }
35
+
36
+ /** base64url decode → Uint8Array. Throws on malformed input (matches Buffer behaviour). */
37
+ function base64urlDecode(input: string): Uint8Array {
38
+ // Add back `=` padding so Buffer can decode (base64 length must be a multiple of 4).
39
+ const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - (input.length % 4));
40
+ const b64 = input.replace(/-/g, "+").replace(/_/g, "/") + pad;
41
+ return new Uint8Array(Buffer.from(b64, "base64"));
42
+ }
43
+
44
+ /** Resolve the HMAC secret. */
45
+ function getSecret(): string {
46
+ const secret = process.env.PAGE_SESSION_SECRET || process.env.API_KEY;
47
+ if (!secret) {
48
+ // Fail-closed: better to refuse to issue cookies than to mint with an
49
+ // empty key (any attacker who learns the implementation can forge).
50
+ throw new Error(
51
+ "page-session: neither PAGE_SESSION_SECRET nor API_KEY is set; refusing to sign/verify",
52
+ );
53
+ }
54
+ return secret;
55
+ }
56
+
57
+ /** Import the HMAC key for crypto.subtle. */
58
+ async function importHmacKey(secret: string): Promise<CryptoKey> {
59
+ const enc = new TextEncoder();
60
+ return crypto.subtle.importKey(
61
+ "raw",
62
+ enc.encode(secret),
63
+ { name: "HMAC", hash: "SHA-256" },
64
+ false,
65
+ ["sign", "verify"],
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Sign a page-session payload. Returns the cookie value (no `Set-Cookie` shell).
71
+ * Caller is responsible for attaching cookie attributes (HttpOnly, Path, etc.).
72
+ */
73
+ export async function signPageSession(payload: PageSessionPayload): Promise<string> {
74
+ const secret = getSecret();
75
+ const key = await importHmacKey(secret);
76
+ const enc = new TextEncoder();
77
+ const payloadJson = JSON.stringify(payload);
78
+ const payloadB64 = base64urlEncode(enc.encode(payloadJson));
79
+ const sigBuf = await crypto.subtle.sign("HMAC", key, enc.encode(payloadB64));
80
+ const sigB64 = base64urlEncode(sigBuf);
81
+ return `${payloadB64}.${sigB64}`;
82
+ }
83
+
84
+ /**
85
+ * Verify a signed page-session token. Returns the parsed payload on success or
86
+ * `null` on any failure — bad shape, signature mismatch, expired, malformed
87
+ * JSON, etc. Signature comparison is constant-time.
88
+ *
89
+ * Caller MUST treat `null` as "no session" — do NOT log the token (it may
90
+ * carry a valid signature an attacker provided). If a tampered cookie is
91
+ * observed and the caller chooses to log, redact via `scrubSecrets` first.
92
+ */
93
+ export async function verifyPageSession(
94
+ token: string | undefined | null,
95
+ ): Promise<PageSessionPayload | null> {
96
+ if (!token || typeof token !== "string") return null;
97
+ const dot = token.indexOf(".");
98
+ // Reject anything that doesn't split into exactly two parts.
99
+ if (dot <= 0 || dot === token.length - 1) return null;
100
+ if (token.indexOf(".", dot + 1) !== -1) return null;
101
+
102
+ const payloadB64 = token.slice(0, dot);
103
+ const sigB64 = token.slice(dot + 1);
104
+
105
+ let secret: string;
106
+ try {
107
+ secret = getSecret();
108
+ } catch {
109
+ return null;
110
+ }
111
+
112
+ let providedSig: Uint8Array;
113
+ try {
114
+ providedSig = base64urlDecode(sigB64);
115
+ } catch {
116
+ return null;
117
+ }
118
+
119
+ let key: CryptoKey;
120
+ try {
121
+ key = await importHmacKey(secret);
122
+ } catch {
123
+ return null;
124
+ }
125
+
126
+ const enc = new TextEncoder();
127
+ let expectedSig: Uint8Array;
128
+ try {
129
+ expectedSig = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(payloadB64)));
130
+ } catch {
131
+ return null;
132
+ }
133
+
134
+ // Length-check FIRST — timingSafeEqual throws if lengths differ.
135
+ if (providedSig.length !== expectedSig.length) return null;
136
+ // Constant-time compare.
137
+ try {
138
+ if (!timingSafeEqual(providedSig, expectedSig)) return null;
139
+ } catch {
140
+ return null;
141
+ }
142
+
143
+ // Parse + validate payload.
144
+ let payloadJson: string;
145
+ try {
146
+ payloadJson = new TextDecoder().decode(base64urlDecode(payloadB64));
147
+ } catch {
148
+ return null;
149
+ }
150
+
151
+ let payload: unknown;
152
+ try {
153
+ payload = JSON.parse(payloadJson);
154
+ } catch {
155
+ return null;
156
+ }
157
+
158
+ if (
159
+ !payload ||
160
+ typeof payload !== "object" ||
161
+ typeof (payload as { pageId?: unknown }).pageId !== "string" ||
162
+ typeof (payload as { exp?: unknown }).exp !== "number"
163
+ ) {
164
+ return null;
165
+ }
166
+
167
+ const parsed = payload as PageSessionPayload;
168
+ const nowSec = Math.floor(Date.now() / 1000);
169
+ if (parsed.exp < nowSec) return null;
170
+
171
+ return parsed;
172
+ }
173
+
174
+ /**
175
+ * Parse the `Cookie` request header for a single named cookie value.
176
+ * Returns `undefined` if the header is missing, empty, or doesn't contain the
177
+ * named cookie. Whitespace tolerant; matches the first occurrence.
178
+ */
179
+ export function parseCookieHeader(
180
+ cookieHeader: string | string[] | undefined,
181
+ name: string,
182
+ ): string | undefined {
183
+ if (!cookieHeader) return undefined;
184
+ const header = Array.isArray(cookieHeader) ? cookieHeader.join("; ") : cookieHeader;
185
+ // Pre-escape regex metacharacters in `name` for safety (we only pass known
186
+ // literals today, but cheap insurance).
187
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
188
+ const re = new RegExp(`(?:^|;\\s*)${escapedName}=([^;]*)`);
189
+ const match = header.match(re);
190
+ return match ? match[1] : undefined;
191
+ }
192
+
193
+ /**
194
+ * One-shot helper: pull `page_session` out of a request's `Cookie` header and
195
+ * verify it. Returns the parsed payload on success, `null` on any failure
196
+ * (no cookie, malformed, bad signature, expired, etc.).
197
+ *
198
+ * Shared by `/@swarm/api/*` (`src/http/page-proxy.ts`) and the authed `/p/:id`
199
+ * branch (`src/http/pages-public.ts`) so both call sites converge on the same
200
+ * verification semantics.
201
+ *
202
+ * Accepts an object with a `headers.cookie` field — duck-typed to keep this
203
+ * helper test-friendly without dragging `node:http` types into the utility.
204
+ */
205
+ export async function extractAndVerifyCookie(req: {
206
+ headers: { cookie?: string | string[] | undefined };
207
+ }): Promise<PageSessionPayload | null> {
208
+ const token = parseCookieHeader(req.headers.cookie, "page_session");
209
+ if (!token) return null;
210
+ return verifyPageSession(token);
211
+ }
212
+
213
+ // ───────────────────────────────────────────────────────────────────────────
214
+ // Cookie issuance helper
215
+ // ───────────────────────────────────────────────────────────────────────────
216
+
217
+ /**
218
+ * Cookie lifetime in seconds. 1 hour. Mirrors `PAGE_SESSION_TTL_SECONDS` in
219
+ * `src/http/pages.ts` (intentionally duplicated here to keep the helper
220
+ * standalone; if the value diverges anywhere, that's the bug).
221
+ */
222
+ const PAGE_SESSION_TTL_SECONDS = 3600;
223
+
224
+ /**
225
+ * Mint a signed page-session token + build the `Set-Cookie` header value for
226
+ * `pageId`. Shared by the bearer-authed launch endpoint (`src/http/pages.ts`)
227
+ * and the password-flow inline mint (`src/http/pages-public.ts`).
228
+ *
229
+ * - `dev=true` → `SameSite=Lax` without `Secure` (works on http://localhost).
230
+ * - `dev=false` → `SameSite=None; Secure` (cross-site iframe embedding in prod).
231
+ *
232
+ * The caller is responsible for setting `Set-Cookie` on the response — this
233
+ * helper only builds the string. TTL is 1 hour; renewed on every issuance.
234
+ */
235
+ export async function issuePageSessionCookie(
236
+ pageId: string,
237
+ opts: { dev: boolean },
238
+ ): Promise<string> {
239
+ const exp = Math.floor(Date.now() / 1000) + PAGE_SESSION_TTL_SECONDS;
240
+ const token = await signPageSession({ pageId, exp });
241
+ const attrs = [
242
+ `page_session=${token}`,
243
+ "HttpOnly",
244
+ "Path=/",
245
+ `Max-Age=${PAGE_SESSION_TTL_SECONDS}`,
246
+ ];
247
+ if (opts.dev) {
248
+ attrs.push("SameSite=Lax");
249
+ } else {
250
+ attrs.push("SameSite=None");
251
+ attrs.push("Secure");
252
+ }
253
+ return attrs.join("; ");
254
+ }
@@ -1,70 +0,0 @@
1
- # Artifacts — Serving Interactive Web Content
2
-
3
- ## Quick Start
4
-
5
- ### Static content
6
- ```bash
7
- # Create your content in a persisted directory
8
- mkdir -p /workspace/personal/artifacts/my-report
9
- echo '<h1>My Report</h1>' > /workspace/personal/artifacts/my-report/index.html
10
-
11
- # Serve it (auto-assigns a free port, creates tunnel)
12
- artifact serve /workspace/personal/artifacts/my-report --name "my-report"
13
- # -> https://{agentId}-my-report.lt.desplega.ai
14
- ```
15
-
16
- ### Programmatic (custom Hono server)
17
- ```typescript
18
- import { createArtifactServer } from '../artifact-sdk';
19
- import { Hono } from 'hono';
20
-
21
- const app = new Hono();
22
- app.get('/', (c) => c.html('<h1>Dashboard</h1>'));
23
-
24
- const server = createArtifactServer({ name: 'dashboard', app });
25
- await server.start();
26
- console.log(`Live at: ${server.url}`);
27
- ```
28
-
29
- ## CLI Commands
30
- - `artifact serve <path> --name <name>` — Start serving content
31
- - `artifact list` — List active artifacts with ports and URLs
32
- - `artifact stop <name>` — Stop an artifact and close its tunnel
33
-
34
- ## Multiple Artifacts
35
- Each artifact gets its own port (auto-assigned) and subdomain. You can serve multiple simultaneously.
36
-
37
- ## Browser SDK
38
- HTML artifacts can interact with the swarm API:
39
- ```html
40
- <script src="/@swarm/sdk.js"></script>
41
- <script>
42
- const swarm = new SwarmSDK();
43
- await swarm.createTask({ task: 'Do something' });
44
- const agents = await swarm.getSwarm();
45
- </script>
46
- ```
47
-
48
- ### Available SDK Methods
49
- - `createTask(opts)` — Create a new task
50
- - `getTasks(filters)` — List tasks with optional filters
51
- - `getTaskDetails(id)` — Get details for a specific task
52
- - `storeProgress(taskId, data)` — Update task progress
53
- - `postMessage(opts)` — Post a message to a channel
54
- - `readMessages(opts)` — Read messages from a channel
55
- - `getSwarm()` — Get list of agents
56
- - `listServices()` — List registered services
57
- - `slackReply(opts)` — Reply to a Slack thread
58
-
59
- ## Auth
60
- Artifacts are protected by HTTP Basic Auth (username: `hi`, password: API key). Credentials are auto-configured.
61
-
62
- ## Storage
63
- Always store artifact content in persisted directories:
64
- - `/workspace/personal/artifacts/` — per-agent, persists across sessions (default)
65
- - `/workspace/shared/artifacts/` — shared across swarm
66
-
67
- ## API Proxy
68
- The `/@swarm/api/*` proxy forwards requests to the MCP server with proper authentication headers. This allows browser-side JavaScript to call swarm APIs without exposing credentials.
69
-
70
- See the `examples/` directory for complete working examples.