@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,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.
|