@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
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Smoke tests for the `<swarm-diff>` custom element shipped inside
3
+ * `SWARM_UI_JS`. Existing browser-side tests in this repo are pure string-
4
+ * content checks against the SDK constant; we don't have happy-dom or jsdom
5
+ * in deps. To still verify the element produces sane DOM output, we evaluate
6
+ * the JS in a hand-rolled stub `window` / `HTMLElement` / `customElements`
7
+ * scaffold — minimal but enough to assert structural properties (row counts,
8
+ * anchor ids, severity badges) without dragging in a real DOM lib.
9
+ */
10
+ import { describe, expect, test } from "bun:test";
11
+ import { SWARM_UI_JS } from "../artifact-sdk/browser-sdk";
12
+
13
+ const EXAMPLE_HUNK = {
14
+ hunks: [
15
+ {
16
+ old_start: 10,
17
+ old_lines: 3,
18
+ new_start: 10,
19
+ new_lines: 4,
20
+ lines: [
21
+ { type: "context", text: " const x = 1;" },
22
+ { type: "del", text: "- console.log(x);" },
23
+ { type: "add", text: "+ logger.info({ x });" },
24
+ { type: "add", text: "+ return x;" },
25
+ ],
26
+ annotations: [{ line: 12, severity: "warn", text: "Avoid raw console.log" }],
27
+ },
28
+ ],
29
+ };
30
+
31
+ type StubInstance = {
32
+ innerHTML: string;
33
+ textContent: string;
34
+ isConnected: boolean;
35
+ connectedCallback?: () => void;
36
+ dispatchEvent: (evt: unknown) => boolean;
37
+ };
38
+
39
+ /**
40
+ * Build a minimal stub window with just enough surface area to load
41
+ * SWARM_UI_JS, register the custom element, and exercise the render path.
42
+ *
43
+ * Returns both the element constructor and a microtask-flush helper so
44
+ * callers can simulate the real-browser parse-order race
45
+ * (`connectedCallback` fires → children get appended → microtask drains).
46
+ */
47
+ function makeRig(): {
48
+ Ctor: new () => StubInstance;
49
+ setText: (el: StubInstance, attrs: Record<string, string>, text: string) => void;
50
+ flushMicrotasks: () => Promise<void>;
51
+ } {
52
+ const registry = new Map<string, new () => StubInstance>();
53
+
54
+ class StubHTMLElement {
55
+ innerHTML = "";
56
+ textContent = "";
57
+ isConnected = true;
58
+ _attrs: Record<string, string> = {};
59
+ getAttribute(name: string): string | null {
60
+ return this._attrs[name] ?? null;
61
+ }
62
+ setAttribute(name: string, value: string): void {
63
+ this._attrs[name] = value;
64
+ }
65
+ connectedCallback?(): void;
66
+ closest(_selector: string): null {
67
+ return null;
68
+ }
69
+ querySelectorAll(_selector: string): unknown[] {
70
+ return [];
71
+ }
72
+ dispatchEvent(_evt: unknown): boolean {
73
+ return true;
74
+ }
75
+ }
76
+
77
+ const customElements = {
78
+ define(name: string, ctor: new () => StubInstance) {
79
+ registry.set(name, ctor);
80
+ },
81
+ get(name: string) {
82
+ return registry.get(name);
83
+ },
84
+ };
85
+
86
+ const win = {
87
+ customElements,
88
+ swarmUi: undefined as { renderDiff?: (rootEl: unknown, data: unknown) => void } | undefined,
89
+ HTMLElement: StubHTMLElement,
90
+ CustomEvent: class {
91
+ constructor(
92
+ public type: string,
93
+ public init?: { bubbles?: boolean; detail?: unknown },
94
+ ) {}
95
+ },
96
+ };
97
+
98
+ // Provide `document` stubs the jump-list path uses.
99
+ const doc = {
100
+ querySelectorAll: () => [],
101
+ addEventListener: () => {},
102
+ removeEventListener: () => {},
103
+ };
104
+
105
+ // Evaluate SWARM_UI_JS with our stubs in scope. The IIFE inside the
106
+ // constant captures `window`, `customElements`, `document`, `HTMLElement`,
107
+ // `CustomEvent`, and `queueMicrotask` — provide each as a free variable.
108
+ const factory = new Function(
109
+ "window",
110
+ "customElements",
111
+ "document",
112
+ "HTMLElement",
113
+ "CustomEvent",
114
+ "queueMicrotask",
115
+ "console",
116
+ `${SWARM_UI_JS}\nreturn window.customElements.get('swarm-diff');`,
117
+ );
118
+ const Ctor = factory(
119
+ win,
120
+ customElements,
121
+ doc,
122
+ StubHTMLElement,
123
+ win.CustomEvent,
124
+ queueMicrotask,
125
+ console,
126
+ ) as (new () => StubInstance) | undefined;
127
+ if (!Ctor) throw new Error("custom element did not register");
128
+
129
+ return {
130
+ Ctor,
131
+ setText(el, attrs, text) {
132
+ (el as unknown as { _attrs: Record<string, string> })._attrs = attrs;
133
+ el.textContent = text;
134
+ },
135
+ async flushMicrotasks() {
136
+ // Two yields: one drains the connectedCallback microtask, the next one
137
+ // drains anything queued by the render path itself.
138
+ await Promise.resolve();
139
+ await Promise.resolve();
140
+ },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Convenience wrapper for the existing "happy path" tests: build the rig,
146
+ * pre-set textContent + attrs, fire connectedCallback, flush microtasks,
147
+ * return innerHTML.
148
+ */
149
+ async function renderViaStub(text: string, attrs: Record<string, string>): Promise<string> {
150
+ const rig = makeRig();
151
+ const el = new rig.Ctor();
152
+ rig.setText(el, attrs, text);
153
+ if (typeof el.connectedCallback === "function") el.connectedCallback();
154
+ await rig.flushMicrotasks();
155
+ return el.innerHTML;
156
+ }
157
+
158
+ describe("SWARM_UI_JS", () => {
159
+ test("is a non-empty string", () => {
160
+ expect(typeof SWARM_UI_JS).toBe("string");
161
+ expect(SWARM_UI_JS.length).toBeGreaterThan(500);
162
+ });
163
+
164
+ test("defines swarm-diff + swarm-diff-jumps custom elements", () => {
165
+ expect(SWARM_UI_JS).toContain("customElements.define('swarm-diff'");
166
+ expect(SWARM_UI_JS).toContain("customElements.define('swarm-diff-jumps'");
167
+ });
168
+
169
+ test("exposes window.swarmUi.renderDiff as a programmatic entry point", () => {
170
+ expect(SWARM_UI_JS).toContain("window.swarmUi");
171
+ expect(SWARM_UI_JS).toContain("renderDiff");
172
+ });
173
+ });
174
+
175
+ describe("<swarm-diff> render", () => {
176
+ test("constructs and renders without throwing on the example input", async () => {
177
+ const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), {
178
+ file: "src/foo.ts",
179
+ "base-sha": "abc123",
180
+ "head-sha": "def456",
181
+ });
182
+ expect(html.length).toBeGreaterThan(0);
183
+ });
184
+
185
+ test("renders one <tr> per line in each hunk", async () => {
186
+ const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), { file: "src/foo.ts" });
187
+ // 4 lines in the example hunk → 4 <tr> rows.
188
+ const trMatches = html.match(/<tr\b/g) || [];
189
+ expect(trMatches.length).toBe(4);
190
+ });
191
+
192
+ test("renders file header and SHA range", async () => {
193
+ const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), {
194
+ file: "src/foo.ts",
195
+ "base-sha": "abc123",
196
+ "head-sha": "def456",
197
+ });
198
+ expect(html).toContain("src/foo.ts");
199
+ expect(html).toContain("abc123");
200
+ expect(html).toContain("def456");
201
+ });
202
+
203
+ test("renders deterministic anchor id per hunk", async () => {
204
+ const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), { file: "src/foo.ts" });
205
+ expect(html).toContain('id="swarm-diff-src-foo-ts-10"');
206
+ });
207
+
208
+ test("renders severity annotation badge on annotated line", async () => {
209
+ const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), { file: "src/foo.ts" });
210
+ expect(html).toContain("WARN");
211
+ expect(html).toContain("Avoid raw console.log");
212
+ });
213
+
214
+ test("handles empty/missing JSON body gracefully (no rows, no throw)", async () => {
215
+ const html = await renderViaStub("", { file: "empty.ts" });
216
+ // Should still render an outer container with the file name.
217
+ expect(html).toContain("empty.ts");
218
+ // But no <tr> rows.
219
+ expect(html.match(/<tr\b/g) ?? []).toHaveLength(0);
220
+ });
221
+
222
+ test("escapes user-controlled text content to prevent injection", async () => {
223
+ const xssHunk = {
224
+ hunks: [
225
+ {
226
+ old_start: 1,
227
+ old_lines: 1,
228
+ new_start: 1,
229
+ new_lines: 1,
230
+ lines: [{ type: "add", text: "<script>alert('xss')</script>" }],
231
+ annotations: [],
232
+ },
233
+ ],
234
+ };
235
+ const html = await renderViaStub(JSON.stringify(xssHunk), { file: "<bad>" });
236
+ expect(html).not.toContain("<script>alert(");
237
+ expect(html).toContain("&lt;script&gt;");
238
+ expect(html).toContain("&lt;bad&gt;");
239
+ });
240
+ });
241
+
242
+ describe("<swarm-diff> parse-order regression (Bug #479-1)", () => {
243
+ // Real browsers fire connectedCallback when the parser sees the opening
244
+ // tag, BEFORE the JSON text children are parsed. The element MUST defer
245
+ // its parseHunks/render so it reads textContent AFTER the parser appends
246
+ // the children. Without the queueMicrotask defer in connectedCallback,
247
+ // the element renders an empty header and the JSON stays visible as
248
+ // orphan text — that's the production bug PR #479 shipped initially.
249
+
250
+ test("does NOT render synchronously inside connectedCallback (defer required)", () => {
251
+ const rig = makeRig();
252
+ const el = new rig.Ctor();
253
+ // Simulate the real-browser parse order: connectedCallback fires while
254
+ // textContent is still empty. The element must NOT have rendered yet
255
+ // — if it does, it's reading textContent too early.
256
+ rig.setText(el, { file: "src/foo.ts" }, "");
257
+ if (typeof el.connectedCallback === "function") el.connectedCallback();
258
+ expect(el.innerHTML).toBe("");
259
+ });
260
+
261
+ test("renders correctly when textContent is appended AFTER connectedCallback but BEFORE microtask drain", async () => {
262
+ const rig = makeRig();
263
+ const el = new rig.Ctor();
264
+ // Parse order: connectedCallback fires with empty textContent, children
265
+ // get appended, then microtask drains.
266
+ rig.setText(el, { file: "src/foo.ts" }, "");
267
+ if (typeof el.connectedCallback === "function") el.connectedCallback();
268
+ // Parser would now append JSON children. Simulate by setting textContent.
269
+ el.textContent = JSON.stringify(EXAMPLE_HUNK);
270
+ await rig.flushMicrotasks();
271
+ // After the microtask drains, the element must have rendered against
272
+ // the post-callback textContent.
273
+ expect(el.innerHTML).toContain("src/foo.ts");
274
+ expect(el.innerHTML.match(/<tr\b/g) ?? []).toHaveLength(4);
275
+ expect(el.innerHTML).toContain("WARN");
276
+ });
277
+
278
+ test("re-renders cleanly on reconnection (connectedCallback fires again)", async () => {
279
+ const rig = makeRig();
280
+ const el = new rig.Ctor();
281
+ rig.setText(el, { file: "src/foo.ts" }, JSON.stringify(EXAMPLE_HUNK));
282
+ if (typeof el.connectedCallback === "function") el.connectedCallback();
283
+ await rig.flushMicrotasks();
284
+ const firstRender = el.innerHTML;
285
+ expect(firstRender).toContain("src/foo.ts");
286
+ // Re-fire (element was moved or detached + reattached).
287
+ if (typeof el.connectedCallback === "function") el.connectedCallback();
288
+ await rig.flushMicrotasks();
289
+ expect(el.innerHTML).toContain("src/foo.ts");
290
+ expect(el.innerHTML.match(/<tr\b/g) ?? []).toHaveLength(4);
291
+ });
292
+
293
+ test("aborts render if element disconnected before microtask drains", async () => {
294
+ const rig = makeRig();
295
+ const el = new rig.Ctor();
296
+ rig.setText(el, { file: "src/foo.ts" }, JSON.stringify(EXAMPLE_HUNK));
297
+ if (typeof el.connectedCallback === "function") el.connectedCallback();
298
+ // Element gets removed from the DOM before our microtask runs.
299
+ el.isConnected = false;
300
+ await rig.flushMicrotasks();
301
+ expect(el.innerHTML).toBe("");
302
+ });
303
+ });
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import {
3
3
  _getInstallationIdForTests,
4
4
  _resetTelemetryStateForTests,
5
+ _resolveCloudMode,
5
6
  initTelemetry,
6
7
  track,
7
8
  } from "../telemetry";
@@ -13,6 +14,9 @@ process.env.ANONYMIZED_TELEMETRY = "true";
13
14
  describe("initTelemetry", () => {
14
15
  beforeEach(() => {
15
16
  _resetTelemetryStateForTests();
17
+ // Tests below set MCP_BASE_URL to assert classification — clear between
18
+ // tests so cases that expect "unset" don't inherit a prior test's value.
19
+ delete process.env.MCP_BASE_URL;
16
20
  });
17
21
 
18
22
  test("without generateIfMissing + missing config → installationId stays null (track no-ops)", async () => {
@@ -241,4 +245,149 @@ describe("initTelemetry", () => {
241
245
  expect(_getInstallationIdForTests()).toBe(existing);
242
246
  expect(writesB).toEqual([]);
243
247
  });
248
+
249
+ describe("_resolveCloudMode (URL → is_cloud)", () => {
250
+ test("cloud apex host → cloud=true", () => {
251
+ expect(_resolveCloudMode("https://agent-swarm-mcp.desplega.sh")).toEqual({ isCloud: true });
252
+ expect(_resolveCloudMode("https://api.agent-swarm.dev")).toEqual({ isCloud: true });
253
+ expect(_resolveCloudMode("https://agent-swarm.dev")).toEqual({ isCloud: true });
254
+ // Future cloud subdomains (suffix match)
255
+ expect(_resolveCloudMode("https://mcp.agent-swarm.dev/")).toEqual({ isCloud: true });
256
+ // Trailing path / port / auth must not change the host classification
257
+ expect(_resolveCloudMode("https://user:tok@api.agent-swarm.dev:443/api/foo?x=1")).toEqual({
258
+ isCloud: true,
259
+ });
260
+ // Case-insensitive
261
+ expect(_resolveCloudMode("https://API.Agent-Swarm.DEV")).toEqual({ isCloud: true });
262
+ });
263
+
264
+ test("agent-swarm.cloud apex host → cloud=true", () => {
265
+ // Exact apex
266
+ expect(_resolveCloudMode("https://agent-swarm.cloud")).toEqual({ isCloud: true });
267
+ // Suffix subdomain
268
+ expect(_resolveCloudMode("https://api.agent-swarm.cloud")).toEqual({ isCloud: true });
269
+ expect(_resolveCloudMode("https://mcp.agent-swarm.cloud/")).toEqual({ isCloud: true });
270
+ // Trailing path / port / auth must not change classification
271
+ expect(_resolveCloudMode("https://user:tok@api.agent-swarm.cloud:443/api/foo?x=1")).toEqual({
272
+ isCloud: true,
273
+ });
274
+ // Case-insensitive
275
+ expect(_resolveCloudMode("https://API.Agent-Swarm.CLOUD")).toEqual({ isCloud: true });
276
+ });
277
+
278
+ test("self-hosted hosts → cloud=false", () => {
279
+ expect(_resolveCloudMode("http://localhost:3013")).toEqual({ isCloud: false });
280
+ expect(_resolveCloudMode("https://my-internal-mcp.example.com")).toEqual({ isCloud: false });
281
+ // Substring trap — must NOT be treated as cloud
282
+ expect(_resolveCloudMode("https://agent-swarm.dev.attacker.com")).toEqual({ isCloud: false });
283
+ expect(_resolveCloudMode("https://agent-swarm.cloud.attacker.com")).toEqual({
284
+ isCloud: false,
285
+ });
286
+ // IPv4 self-host
287
+ expect(_resolveCloudMode("http://127.0.0.1:3013")).toEqual({ isCloud: false });
288
+ });
289
+
290
+ test("bare hostname / unset / weird scheme → safe fallback", () => {
291
+ // Bare hostname (no scheme) — URL constructor throws
292
+ expect(_resolveCloudMode("agent-swarm-mcp.desplega.sh")).toEqual({ isCloud: false });
293
+ // Empty / undefined / null
294
+ expect(_resolveCloudMode(undefined)).toEqual({ isCloud: false });
295
+ expect(_resolveCloudMode(null)).toEqual({ isCloud: false });
296
+ expect(_resolveCloudMode("")).toEqual({ isCloud: false });
297
+ // Obvious garbage
298
+ expect(_resolveCloudMode("not a url")).toEqual({ isCloud: false });
299
+ // Weird scheme with no host component
300
+ expect(_resolveCloudMode("file:///tmp/foo")).toEqual({ isCloud: false });
301
+ });
302
+ });
303
+
304
+ describe("track() ships is_cloud in properties", () => {
305
+ const originalFetch = globalThis.fetch;
306
+ let captured: Record<string, unknown> | null = null;
307
+
308
+ beforeEach(() => {
309
+ captured = null;
310
+ globalThis.fetch = (async (_url: string, init?: { body?: string }) => {
311
+ captured = init?.body ? JSON.parse(init.body) : null;
312
+ return new Response(null, { status: 204 });
313
+ }) as typeof fetch;
314
+ });
315
+
316
+ afterEach(() => {
317
+ globalThis.fetch = originalFetch;
318
+ delete process.env.MCP_BASE_URL;
319
+ });
320
+
321
+ test("cloud MCP_BASE_URL → properties.is_cloud=true", async () => {
322
+ process.env.MCP_BASE_URL = "https://agent-swarm-mcp.desplega.sh";
323
+ await initTelemetry(
324
+ "worker",
325
+ async () => "install_cloud_test",
326
+ async () => {},
327
+ );
328
+
329
+ track({ event: "server.started", properties: { port: 3013 } });
330
+ await new Promise((r) => setTimeout(r, 0));
331
+
332
+ const properties = (captured as { properties: Record<string, unknown> }).properties;
333
+ expect(properties.is_cloud).toBe(true);
334
+ // Hostname must NOT be emitted — telemetry is anonymous.
335
+ expect(properties.mcp_host).toBeUndefined();
336
+ // Caller's properties preserved alongside the cohort signal.
337
+ expect(properties.port).toBe(3013);
338
+ });
339
+
340
+ test("self-hosted MCP_BASE_URL → properties.is_cloud=false", async () => {
341
+ process.env.MCP_BASE_URL = "http://localhost:3013";
342
+ await initTelemetry(
343
+ "worker",
344
+ async () => "install_self_test",
345
+ async () => {},
346
+ );
347
+
348
+ track({ event: "test.event", properties: {} });
349
+ await new Promise((r) => setTimeout(r, 0));
350
+
351
+ const properties = (captured as { properties: Record<string, unknown> }).properties;
352
+ expect(properties.is_cloud).toBe(false);
353
+ expect(properties.mcp_host).toBeUndefined();
354
+ });
355
+
356
+ test("missing MCP_BASE_URL → safe fallback (false)", async () => {
357
+ delete process.env.MCP_BASE_URL;
358
+ await initTelemetry(
359
+ "api-server",
360
+ async () => "install_no_url",
361
+ async () => {},
362
+ );
363
+
364
+ track({ event: "test.event", properties: {} });
365
+ await new Promise((r) => setTimeout(r, 0));
366
+
367
+ const properties = (captured as { properties: Record<string, unknown> }).properties;
368
+ expect(properties.is_cloud).toBe(false);
369
+ expect(properties.mcp_host).toBeUndefined();
370
+ });
371
+
372
+ test("caller properties cannot override is_cloud", async () => {
373
+ // Defense-in-depth: even if a caller passes through user-supplied
374
+ // values, the cohort signal shipped on every event must come from
375
+ // initTelemetry — not from arbitrary call sites.
376
+ process.env.MCP_BASE_URL = "https://agent-swarm-mcp.desplega.sh";
377
+ await initTelemetry(
378
+ "worker",
379
+ async () => "install_override_test",
380
+ async () => {},
381
+ );
382
+
383
+ track({
384
+ event: "test.event",
385
+ properties: { is_cloud: false },
386
+ });
387
+ await new Promise((r) => setTimeout(r, 0));
388
+
389
+ const properties = (captured as { properties: Record<string, unknown> }).properties;
390
+ expect(properties.is_cloud).toBe(true);
391
+ });
392
+ });
244
393
  });
@@ -261,13 +261,10 @@ describe("WaitExecutor — event mode end-to-end", () => {
261
261
 
262
262
  // Skip the 5s poller — fast-forward by directly calling the resume helper
263
263
  // with status='timeout' (the poller would do exactly this once expiresAt
264
- // passes). Sleep relative to the *actual* expiresAt so we don't race
265
- // when startWorkflowExecution overhead eats the cushion on slow CI.
266
- const expiresAtMs = new Date(ws!.expiresAt!).getTime();
267
- const sleepMs = Math.max(0, expiresAtMs - Date.now()) + 250;
268
- await new Promise((r) => setTimeout(r, sleepMs));
269
- const due = getDueWaitStates();
270
- expect(due.find((d) => d.id === ws!.id)).toBeDefined();
264
+ // passes). Poll getDueWaitStates() instead of sleeping a fixed amount:
265
+ // SQLite's strftime('now') and JS Date.now() can drift by a few ms on
266
+ // loaded CI, and setTimeout cushions need to be generous to compensate.
267
+ await waitFor(() => getDueWaitStates().some((d) => d.id === ws!.id), 5000);
271
268
 
272
269
  await resumeWaitState(ws!.id, "timeout", undefined, registry);
273
270