@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.
Files changed (75) hide show
  1. package/README.md +1 -0
  2. package/openapi.json +1335 -236
  3. package/package.json +4 -4
  4. package/plugin/skills/artifacts/SKILL.md +151 -0
  5. package/plugin/skills/artifacts/examples/static-report.sh +1 -1
  6. package/plugin/skills/kv-storage/SKILL.md +168 -0
  7. package/plugin/skills/pages/SKILL.md +423 -0
  8. package/src/artifact-sdk/browser-sdk.ts +396 -19
  9. package/src/be/db.ts +548 -0
  10. package/src/be/migrations/059_pages.sql +34 -0
  11. package/src/be/migrations/060_page_versions.sql +19 -0
  12. package/src/be/migrations/061_kv_store.sql +34 -0
  13. package/src/be/migrations/062_pages_view_count.sql +9 -0
  14. package/src/commands/artifact.ts +17 -11
  15. package/src/commands/provider-credentials.ts +1 -1
  16. package/src/http/index.ts +9 -1
  17. package/src/http/kv.ts +658 -0
  18. package/src/http/page-proxy.ts +213 -0
  19. package/src/http/pages-public.ts +507 -0
  20. package/src/http/pages.ts +608 -0
  21. package/src/http/status.ts +1 -1
  22. package/src/http/utils.ts +68 -5
  23. package/src/pages/version.ts +44 -0
  24. package/src/prompts/session-templates.ts +51 -0
  25. package/src/providers/pi-mono-adapter.ts +3 -3
  26. package/src/providers/pi-mono-extension.ts +1 -1
  27. package/src/server.ts +29 -1
  28. package/src/tasks/context-key.ts +28 -0
  29. package/src/telemetry.ts +65 -1
  30. package/src/tests/artifact-commands.test.ts +92 -0
  31. package/src/tests/artifact-sdk.test.ts +80 -74
  32. package/src/tests/context-key.test.ts +17 -0
  33. package/src/tests/create-page-tool.test.ts +197 -0
  34. package/src/tests/fixtures/sample-json-page.json +52 -0
  35. package/src/tests/kv-http.test.ts +331 -0
  36. package/src/tests/kv-namespace-resolution.test.ts +172 -0
  37. package/src/tests/kv-page-proxy.test.ts +212 -0
  38. package/src/tests/kv-storage.test.ts +227 -0
  39. package/src/tests/kv-tool.test.ts +217 -0
  40. package/src/tests/launch-password-rejection.test.ts +139 -0
  41. package/src/tests/page-proxy-authed.test.ts +146 -0
  42. package/src/tests/page-proxy.test.ts +270 -0
  43. package/src/tests/page-session.test.ts +169 -0
  44. package/src/tests/pages-actions-endpoint.test.ts +102 -0
  45. package/src/tests/pages-authed-mode.test.ts +211 -0
  46. package/src/tests/pages-http.test.ts +193 -0
  47. package/src/tests/pages-list-endpoint.test.ts +149 -0
  48. package/src/tests/pages-password-hash.test.ts +57 -0
  49. package/src/tests/pages-password-mode.test.ts +265 -0
  50. package/src/tests/pages-public-authed-401.test.ts +102 -0
  51. package/src/tests/pages-public-html.test.ts +151 -0
  52. package/src/tests/pages-public-json-redirect.test.ts +86 -0
  53. package/src/tests/pages-storage.test.ts +196 -0
  54. package/src/tests/pages-versioning.test.ts +231 -0
  55. package/src/tests/pages-view-count.test.ts +220 -0
  56. package/src/tests/prompt-template-session.test.ts +3 -2
  57. package/src/tests/skill-update-scope.test.ts +165 -0
  58. package/src/tests/swarm-diff.test.ts +303 -0
  59. package/src/tests/telemetry-init.test.ts +149 -0
  60. package/src/tests/workflow-wait-event.test.ts +4 -7
  61. package/src/tools/create-page.ts +263 -0
  62. package/src/tools/kv/index.ts +5 -0
  63. package/src/tools/kv/kv-delete.ts +89 -0
  64. package/src/tools/kv/kv-get.ts +64 -0
  65. package/src/tools/kv/kv-incr.ts +116 -0
  66. package/src/tools/kv/kv-list.ts +81 -0
  67. package/src/tools/kv/kv-set.ts +194 -0
  68. package/src/tools/kv/resolve-namespace.ts +58 -0
  69. package/src/tools/skills/skill-update.ts +26 -0
  70. package/src/tools/tool-config.ts +10 -0
  71. package/src/types.ts +107 -0
  72. package/src/utils/internal-ai/complete-structured.ts +2 -2
  73. package/src/utils/internal-ai/credentials.ts +3 -3
  74. package/src/utils/page-session.ts +254 -0
  75. 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
- res.setHeader("Access-Control-Allow-Origin", "*");
6
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
7
- res.setHeader("Access-Control-Allow-Headers", "*");
8
- res.setHeader("Access-Control-Expose-Headers", "*");
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 "@mariozechner/pi-ai";
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 "@mariozechner/pi-coding-agent";
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 "@mariozechner/pi-coding-agent";
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 "@mariozechner/pi-coding-agent";
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 = "core,task-pool,profiles,services,scheduling,memory,workflows";
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);
@@ -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: options.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
+ });