@desplega.ai/agent-swarm 1.78.1 → 1.79.1
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/README.md +1 -0
- package/openapi.json +1335 -236
- package/package.json +4 -4
- package/plugin/skills/artifacts/SKILL.md +151 -0
- package/plugin/skills/artifacts/examples/static-report.sh +1 -1
- package/plugin/skills/kv-storage/SKILL.md +168 -0
- package/plugin/skills/pages/SKILL.md +423 -0
- package/src/artifact-sdk/browser-sdk.ts +396 -19
- package/src/be/db.ts +548 -0
- package/src/be/migrations/059_pages.sql +34 -0
- package/src/be/migrations/060_page_versions.sql +19 -0
- package/src/be/migrations/061_kv_store.sql +34 -0
- package/src/be/migrations/062_pages_view_count.sql +9 -0
- package/src/commands/artifact.ts +17 -11
- package/src/commands/provider-credentials.ts +1 -1
- package/src/http/index.ts +9 -1
- package/src/http/kv.ts +658 -0
- package/src/http/page-proxy.ts +213 -0
- package/src/http/pages-public.ts +507 -0
- package/src/http/pages.ts +608 -0
- package/src/http/status.ts +1 -1
- package/src/http/utils.ts +68 -5
- package/src/pages/version.ts +44 -0
- package/src/prompts/session-templates.ts +51 -0
- package/src/providers/pi-mono-adapter.ts +3 -3
- package/src/providers/pi-mono-extension.ts +1 -1
- package/src/server.ts +29 -1
- package/src/tasks/context-key.ts +28 -0
- package/src/telemetry.ts +65 -1
- package/src/tests/artifact-commands.test.ts +92 -0
- package/src/tests/artifact-sdk.test.ts +80 -74
- package/src/tests/context-key.test.ts +17 -0
- package/src/tests/create-page-tool.test.ts +197 -0
- package/src/tests/fixtures/sample-json-page.json +52 -0
- package/src/tests/kv-http.test.ts +331 -0
- package/src/tests/kv-namespace-resolution.test.ts +172 -0
- package/src/tests/kv-page-proxy.test.ts +212 -0
- package/src/tests/kv-storage.test.ts +227 -0
- package/src/tests/kv-tool.test.ts +217 -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 +270 -0
- package/src/tests/page-session.test.ts +169 -0
- package/src/tests/pages-actions-endpoint.test.ts +102 -0
- package/src/tests/pages-authed-mode.test.ts +211 -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/pages-view-count.test.ts +220 -0
- package/src/tests/prompt-template-session.test.ts +3 -2
- package/src/tests/skill-update-scope.test.ts +165 -0
- package/src/tests/swarm-diff.test.ts +303 -0
- package/src/tests/telemetry-init.test.ts +149 -0
- package/src/tests/workflow-wait-event.test.ts +4 -7
- package/src/tools/create-page.ts +263 -0
- package/src/tools/kv/index.ts +5 -0
- package/src/tools/kv/kv-delete.ts +89 -0
- package/src/tools/kv/kv-get.ts +64 -0
- package/src/tools/kv/kv-incr.ts +116 -0
- package/src/tools/kv/kv-list.ts +81 -0
- package/src/tools/kv/kv-set.ts +194 -0
- package/src/tools/kv/resolve-namespace.ts +58 -0
- package/src/tools/skills/skill-update.ts +26 -0
- package/src/tools/tool-config.ts +10 -0
- package/src/types.ts +107 -0
- package/src/utils/internal-ai/complete-structured.ts +2 -2
- package/src/utils/internal-ai/credentials.ts +3 -3
- package/src/utils/page-session.ts +254 -0
- package/plugin/skills/artifacts/skill.md +0 -70
package/src/http/utils.ts
CHANGED
|
@@ -1,11 +1,37 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { getActiveTaskCount } from "../be/db";
|
|
3
3
|
|
|
4
|
-
export function setCorsHeaders(res: ServerResponse) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
export function setCorsHeaders(req: IncomingMessage, res: ServerResponse) {
|
|
5
|
+
// Echo the request Origin (rather than emitting `*`) so credentialed fetches
|
|
6
|
+
// — e.g. the SPA's `credentials: 'include'` calls to `/p/:id.json` and the
|
|
7
|
+
// page-session cookie endpoints — pass the browser's CORS check. A wildcard
|
|
8
|
+
// would force the browser to reject any credentialed cross-origin response.
|
|
9
|
+
const rawOrigin = req.headers.origin;
|
|
10
|
+
const origin = Array.isArray(rawOrigin) ? rawOrigin[0] : rawOrigin;
|
|
11
|
+
if (origin) {
|
|
12
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
13
|
+
res.setHeader("Vary", "Origin");
|
|
14
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
15
|
+
// When credentials are involved the spec disallows wildcards in
|
|
16
|
+
// Allow-Headers / Allow-Methods / Expose-Headers — they must be
|
|
17
|
+
// explicit. Echo whatever the preflight asked for (defensive default
|
|
18
|
+
// covers Authorization + the common app headers).
|
|
19
|
+
const reqHeaders = req.headers["access-control-request-headers"];
|
|
20
|
+
const askedHeaders = Array.isArray(reqHeaders) ? reqHeaders.join(", ") : reqHeaders;
|
|
21
|
+
res.setHeader(
|
|
22
|
+
"Access-Control-Allow-Headers",
|
|
23
|
+
askedHeaders ?? "Authorization, Content-Type, X-Agent-ID, X-Requested-With",
|
|
24
|
+
);
|
|
25
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
26
|
+
res.setHeader("Access-Control-Expose-Headers", "Content-Type, Content-Length, ETag, Location");
|
|
27
|
+
} else {
|
|
28
|
+
// No Origin (curl / direct browser nav) — wildcards are fine and avoid
|
|
29
|
+
// breaking non-browser callers.
|
|
30
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
31
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
32
|
+
res.setHeader("Access-Control-Allow-Headers", "*");
|
|
33
|
+
res.setHeader("Access-Control-Expose-Headers", "*");
|
|
34
|
+
}
|
|
9
35
|
}
|
|
10
36
|
|
|
11
37
|
export function parseQueryParams(url: string): URLSearchParams {
|
|
@@ -45,6 +71,43 @@ export async function parseBody<T = unknown>(req: IncomingMessage): Promise<T> {
|
|
|
45
71
|
return JSON.parse(Buffer.concat(chunks).toString()) as T;
|
|
46
72
|
}
|
|
47
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Sentinel returned by `enforceContentLengthCap` when the request exceeds the
|
|
76
|
+
* provided byte cap. The caller has already received a `413` response — it
|
|
77
|
+
* should stop processing the request immediately.
|
|
78
|
+
*/
|
|
79
|
+
export const BODY_TOO_LARGE = Symbol("body-too-large");
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Reject the request with `413 Payload Too Large` when its `Content-Length`
|
|
83
|
+
* header exceeds `maxBytes`. Returns `BODY_TOO_LARGE` after writing the
|
|
84
|
+
* response (caller short-circuits); otherwise returns `null` and processing
|
|
85
|
+
* continues.
|
|
86
|
+
*
|
|
87
|
+
* This is a cheap pre-flight; downstream `parseBody`/streamed parsers can be
|
|
88
|
+
* a second defence if a malicious client lies about Content-Length.
|
|
89
|
+
*
|
|
90
|
+
* Used by `/api/pages` POST/PUT to bound the per-row body size — page bodies
|
|
91
|
+
* land in SQLite as a TEXT column and there is no per-instance quota yet.
|
|
92
|
+
*/
|
|
93
|
+
export function enforceContentLengthCap(
|
|
94
|
+
req: IncomingMessage,
|
|
95
|
+
res: ServerResponse,
|
|
96
|
+
maxBytes: number,
|
|
97
|
+
): typeof BODY_TOO_LARGE | null {
|
|
98
|
+
const raw = req.headers["content-length"];
|
|
99
|
+
const val = Array.isArray(raw) ? raw[0] : raw;
|
|
100
|
+
if (!val) return null; // No header — best-effort; parseBody will still buffer.
|
|
101
|
+
const n = Number(val);
|
|
102
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
103
|
+
if (n > maxBytes) {
|
|
104
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
105
|
+
res.end(JSON.stringify({ error: `Payload too large (max ${maxBytes} bytes)` }));
|
|
106
|
+
return BODY_TOO_LARGE;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
48
111
|
/** Send JSON response */
|
|
49
112
|
export function json(res: ServerResponse, data: unknown, status = 200) {
|
|
50
113
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
@@ -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" },
|
|
@@ -8,20 +8,20 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, lstatSync, symlinkSync, unlinkSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import { getModel } from "@
|
|
11
|
+
import { getModel } from "@earendil-works/pi-ai";
|
|
12
12
|
import type {
|
|
13
13
|
AgentSessionEvent,
|
|
14
14
|
CreateAgentSessionOptions,
|
|
15
15
|
SessionStats,
|
|
16
16
|
ToolDefinition,
|
|
17
|
-
} from "@
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import {
|
|
19
19
|
type AgentSession,
|
|
20
20
|
createAgentSession,
|
|
21
21
|
DefaultResourceLoader,
|
|
22
22
|
getAgentDir,
|
|
23
23
|
SessionManager,
|
|
24
|
-
} from "@
|
|
24
|
+
} from "@earendil-works/pi-coding-agent";
|
|
25
25
|
import { type TSchema, Type } from "typebox";
|
|
26
26
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
27
27
|
import { createSwarmHooksExtension } from "./pi-mono-extension";
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* with full behavioral parity.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { ExtensionFactory } from "@
|
|
9
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { buildRatingsFromLlm, fetchRetrievalsForTask, postRatings } from "../be/memory/raters/llm";
|
|
11
11
|
import { checkToolLoop, clearToolHistory } from "../hooks/tool-loop-detection";
|
|
12
12
|
import { summarizeSession as runSummarize } from "../utils/internal-ai";
|
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";
|
|
@@ -12,6 +13,14 @@ import { registerGetTaskDetailsTool } from "./tools/get-task-details";
|
|
|
12
13
|
import { registerGetTasksTool } from "./tools/get-tasks";
|
|
13
14
|
import { registerInjectLearningTool } from "./tools/inject-learning";
|
|
14
15
|
import { registerJoinSwarmTool } from "./tools/join-swarm";
|
|
16
|
+
// KV capability
|
|
17
|
+
import {
|
|
18
|
+
registerKvDeleteTool,
|
|
19
|
+
registerKvGetTool,
|
|
20
|
+
registerKvIncrTool,
|
|
21
|
+
registerKvListTool,
|
|
22
|
+
registerKvSetTool,
|
|
23
|
+
} from "./tools/kv";
|
|
15
24
|
// Messaging capability
|
|
16
25
|
import { registerListChannelsTool } from "./tools/list-channels";
|
|
17
26
|
import { registerListServicesTool } from "./tools/list-services";
|
|
@@ -120,7 +129,8 @@ import {
|
|
|
120
129
|
|
|
121
130
|
// Capability-based feature flags
|
|
122
131
|
// Default: all capabilities enabled
|
|
123
|
-
const DEFAULT_CAPABILITIES =
|
|
132
|
+
const DEFAULT_CAPABILITIES =
|
|
133
|
+
"core,task-pool,profiles,services,scheduling,memory,workflows,pages,kv";
|
|
124
134
|
const CAPABILITIES = new Set(
|
|
125
135
|
(process.env.CAPABILITIES || DEFAULT_CAPABILITIES).split(",").map((s) => s.trim()),
|
|
126
136
|
);
|
|
@@ -284,6 +294,24 @@ export function createServer() {
|
|
|
284
294
|
registerSkillSyncRemoteTool(server);
|
|
285
295
|
registerSkillPublishTool(server);
|
|
286
296
|
|
|
297
|
+
// Pages capability - DB-backed lightweight artifacts (HTML / JSON specs).
|
|
298
|
+
// Enabled by default (added to DEFAULT_CAPABILITIES in step-9 of the
|
|
299
|
+
// db-backed-pages plan). Operators can disable via explicit
|
|
300
|
+
// `CAPABILITIES=...` env without `pages`.
|
|
301
|
+
if (hasCapability("pages")) {
|
|
302
|
+
registerCreatePageTool(server);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// KV capability — namespaced Redis-like key/value (see src/be/migrations/061_kv_store.sql).
|
|
306
|
+
// Enabled by default; opt out via `CAPABILITIES=...` without `kv`.
|
|
307
|
+
if (hasCapability("kv")) {
|
|
308
|
+
registerKvGetTool(server);
|
|
309
|
+
registerKvSetTool(server);
|
|
310
|
+
registerKvDeleteTool(server);
|
|
311
|
+
registerKvIncrTool(server);
|
|
312
|
+
registerKvListTool(server);
|
|
313
|
+
}
|
|
314
|
+
|
|
287
315
|
// MCP Servers - always registered
|
|
288
316
|
registerMcpServerCreateTool(server);
|
|
289
317
|
registerMcpServerUpdateTool(server);
|
package/src/tasks/context-key.ts
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* a task belongs to. We persist the key on `agent_tasks.contextKey` so that a
|
|
7
7
|
* single indexed lookup can return all sibling tasks for a given entity.
|
|
8
8
|
*
|
|
9
|
+
* The same vocabulary is reused by the KV store (`kv_entries.namespace`) so a
|
|
10
|
+
* caller can read/write state scoped to the entity that triggered the call
|
|
11
|
+
* (Slack thread, PR, Linear issue, agent, page, ...) without inventing a
|
|
12
|
+
* separate namespace scheme.
|
|
13
|
+
*
|
|
9
14
|
* Key schema:
|
|
10
15
|
* task:slack:{channelId}:{threadTs}
|
|
11
16
|
* task:agentmail:{threadId}
|
|
@@ -15,6 +20,8 @@
|
|
|
15
20
|
* task:trackers:jira:{issueIdentifier} (e.g. PROJ-123 — case preserved)
|
|
16
21
|
* task:schedule:{scheduleId}
|
|
17
22
|
* task:workflow:{workflowRunId}
|
|
23
|
+
* task:agent:{agentId} (KV-only: per-agent scratchpad)
|
|
24
|
+
* task:page:{pageId} (KV-only: per-page state, proxy-enforced)
|
|
18
25
|
*
|
|
19
26
|
* Rules:
|
|
20
27
|
* - Fixed prefix tokens (`task`, family, sub-family, kind) are always lowercase.
|
|
@@ -157,6 +164,27 @@ export function workflowContextKey(input: { workflowRunId: string }): string {
|
|
|
157
164
|
return ["task", "workflow", workflowRunId].join(SEPARATOR);
|
|
158
165
|
}
|
|
159
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Per-agent KV scratchpad namespace. Not used as a task `contextKey` (tasks
|
|
169
|
+
* always derive their context from an ingress entity), but reused here so the
|
|
170
|
+
* KV layer can resolve "the caller has no task header, just an agent id" into
|
|
171
|
+
* a stable namespace string with the same shape as everything else.
|
|
172
|
+
*/
|
|
173
|
+
export function agentContextKey(input: { agentId: string }): string {
|
|
174
|
+
const agentId = assertSafePart(input.agentId, "agentId");
|
|
175
|
+
return ["task", "agent", agentId].join(SEPARATOR);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Per-page KV namespace. Enforced at the page-proxy boundary
|
|
180
|
+
* (src/http/page-proxy.ts) — pages can never write outside their own
|
|
181
|
+
* `task:page:<id>` namespace regardless of what the request body or URL says.
|
|
182
|
+
*/
|
|
183
|
+
export function pageContextKey(input: { pageId: string }): string {
|
|
184
|
+
const pageId = assertSafePart(input.pageId, "pageId");
|
|
185
|
+
return ["task", "page", pageId].join(SEPARATOR);
|
|
186
|
+
}
|
|
187
|
+
|
|
160
188
|
/**
|
|
161
189
|
* Parse a context key back into a structured form. Throws on malformed input.
|
|
162
190
|
* Useful for diagnostics and downstream routing; not used on the hot insert path.
|
package/src/telemetry.ts
CHANGED
|
@@ -14,11 +14,60 @@ const TIMEOUT_MS = 5_000;
|
|
|
14
14
|
|
|
15
15
|
let installationId: string | null = null;
|
|
16
16
|
let source = "unknown";
|
|
17
|
+
let cachedIsCloud = false;
|
|
17
18
|
|
|
18
19
|
function isEnabled(): boolean {
|
|
19
20
|
return process.env.ANONYMIZED_TELEMETRY !== "false";
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Hosts we own that indicate a cloud-pointed install. Exact-match for known
|
|
25
|
+
* hostnames + suffix-match for the cloud apexes so future cloud subdomains
|
|
26
|
+
* (`mcp.agent-swarm.dev`, `api.agent-swarm.cloud`, etc.) are automatically
|
|
27
|
+
* classified as cloud. Substring match is intentionally avoided —
|
|
28
|
+
* `agent-swarm.dev.attacker.com` must NOT be treated as cloud.
|
|
29
|
+
*/
|
|
30
|
+
const CLOUD_HOST_EXACT = new Set<string>([
|
|
31
|
+
"agent-swarm-mcp.desplega.sh",
|
|
32
|
+
"agent-swarm.dev",
|
|
33
|
+
"agent-swarm.cloud",
|
|
34
|
+
]);
|
|
35
|
+
const CLOUD_HOST_SUFFIXES = [".agent-swarm.dev", ".agent-swarm.cloud"];
|
|
36
|
+
|
|
37
|
+
function isCloudHostname(hostname: string): boolean {
|
|
38
|
+
if (!hostname) return false;
|
|
39
|
+
const normalized = hostname.toLowerCase();
|
|
40
|
+
if (CLOUD_HOST_EXACT.has(normalized)) return true;
|
|
41
|
+
return CLOUD_HOST_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse `MCP_BASE_URL` (or any candidate URL) into the cloud flag we ship on
|
|
46
|
+
* every telemetry event. URL parsing — not substring match — so we never
|
|
47
|
+
* confuse an attacker-controlled `agent-swarm.dev.bad` for cloud. On any
|
|
48
|
+
* parse failure returns a safe `false` so callers never need to defend
|
|
49
|
+
* against this throwing.
|
|
50
|
+
*
|
|
51
|
+
* The hostname itself is intentionally NOT emitted — telemetry is anonymous,
|
|
52
|
+
* and leaking the deployment host would defeat that. Only the boolean
|
|
53
|
+
* cloud-cohort flag ships.
|
|
54
|
+
*
|
|
55
|
+
* Exported for tests; not part of the public API.
|
|
56
|
+
*/
|
|
57
|
+
export function _resolveCloudMode(mcpBaseUrl: string | undefined | null): {
|
|
58
|
+
isCloud: boolean;
|
|
59
|
+
} {
|
|
60
|
+
if (!mcpBaseUrl) return { isCloud: false };
|
|
61
|
+
let hostname: string;
|
|
62
|
+
try {
|
|
63
|
+
hostname = new URL(mcpBaseUrl).hostname;
|
|
64
|
+
} catch {
|
|
65
|
+
return { isCloud: false };
|
|
66
|
+
}
|
|
67
|
+
if (!hostname) return { isCloud: false };
|
|
68
|
+
return { isCloud: isCloudHostname(hostname) };
|
|
69
|
+
}
|
|
70
|
+
|
|
22
71
|
interface InitTelemetryOptions {
|
|
23
72
|
/**
|
|
24
73
|
* Whether to mint and persist a new install ID when the config read returns
|
|
@@ -48,6 +97,11 @@ export async function initTelemetry(
|
|
|
48
97
|
if (!isEnabled()) return;
|
|
49
98
|
source = sourceId;
|
|
50
99
|
const generateIfMissing = options.generateIfMissing === true;
|
|
100
|
+
|
|
101
|
+
const resolved = _resolveCloudMode(process.env.MCP_BASE_URL);
|
|
102
|
+
cachedIsCloud = resolved.isCloud;
|
|
103
|
+
console.log(`telemetry: cloud=${cachedIsCloud}`);
|
|
104
|
+
|
|
51
105
|
try {
|
|
52
106
|
const existing = await getConfig("telemetry_installation_id");
|
|
53
107
|
if (existing) {
|
|
@@ -110,7 +164,16 @@ export function track(options: TrackOptions): void {
|
|
|
110
164
|
source,
|
|
111
165
|
actor_mode: "anonymous" as const,
|
|
112
166
|
actor_anonymous_id: installationId,
|
|
113
|
-
properties:
|
|
167
|
+
properties: {
|
|
168
|
+
...(options.properties ?? {}),
|
|
169
|
+
// Cloud-cohort signal derived from MCP_BASE_URL at init time.
|
|
170
|
+
// Placed at the top level of `properties_json` so ClickHouse can
|
|
171
|
+
// GROUP BY without descending into nested objects. Spread LAST so
|
|
172
|
+
// caller-supplied keys can never spoof the cohort classification.
|
|
173
|
+
// The hostname is intentionally NOT included — telemetry must stay
|
|
174
|
+
// anonymous, and the boolean is sufficient to split cloud vs self-host.
|
|
175
|
+
is_cloud: cachedIsCloud,
|
|
176
|
+
},
|
|
114
177
|
metadata: {
|
|
115
178
|
transport: "https",
|
|
116
179
|
schema_version: 1,
|
|
@@ -138,6 +201,7 @@ export function track(options: TrackOptions): void {
|
|
|
138
201
|
export function _resetTelemetryStateForTests(): void {
|
|
139
202
|
installationId = null;
|
|
140
203
|
source = "unknown";
|
|
204
|
+
cachedIsCloud = false;
|
|
141
205
|
}
|
|
142
206
|
|
|
143
207
|
/** Test-only: read the resolved install ID. */
|
|
@@ -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
|
+
});
|