@rudderhq/agent-runtime-utils 0.4.6-canary.8 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/rudder-mcp-contract.d.ts +1028 -0
  6. package/dist/rudder-mcp-contract.d.ts.map +1 -0
  7. package/dist/rudder-mcp-contract.js +257 -0
  8. package/dist/rudder-mcp-contract.js.map +1 -0
  9. package/dist/rudder-mcp-fingerprint.d.ts +4 -0
  10. package/dist/rudder-mcp-fingerprint.d.ts.map +1 -0
  11. package/dist/rudder-mcp-fingerprint.js +23 -0
  12. package/dist/rudder-mcp-fingerprint.js.map +1 -0
  13. package/dist/rudder-mcp-preflight.d.ts +12 -0
  14. package/dist/rudder-mcp-preflight.d.ts.map +1 -0
  15. package/dist/rudder-mcp-preflight.js +324 -0
  16. package/dist/rudder-mcp-preflight.js.map +1 -0
  17. package/dist/rudder-mcp-preflight.test.d.ts +2 -0
  18. package/dist/rudder-mcp-preflight.test.d.ts.map +1 -0
  19. package/dist/rudder-mcp-preflight.test.js +315 -0
  20. package/dist/rudder-mcp-preflight.test.js.map +1 -0
  21. package/dist/rudder-mcp-server.d.ts.map +1 -1
  22. package/dist/rudder-mcp-server.js +3 -0
  23. package/dist/rudder-mcp-server.js.map +1 -1
  24. package/dist/rudder-mcp-tool-descriptors.generated.d.ts +620 -0
  25. package/dist/rudder-mcp-tool-descriptors.generated.d.ts.map +1 -0
  26. package/dist/rudder-mcp-tool-descriptors.generated.js +699 -0
  27. package/dist/rudder-mcp-tool-descriptors.generated.js.map +1 -0
  28. package/dist/rudder-mcp.d.ts +32 -2
  29. package/dist/rudder-mcp.d.ts.map +1 -1
  30. package/dist/rudder-mcp.js +28 -9
  31. package/dist/rudder-mcp.js.map +1 -1
  32. package/dist/rudder-mcp.test.js +38 -2
  33. package/dist/rudder-mcp.test.js.map +1 -1
  34. package/dist/server-utils.cli.d.ts +41 -3
  35. package/dist/server-utils.cli.d.ts.map +1 -1
  36. package/dist/server-utils.cli.js +307 -23
  37. package/dist/server-utils.cli.js.map +1 -1
  38. package/dist/server-utils.prompts.d.ts +8 -6
  39. package/dist/server-utils.prompts.d.ts.map +1 -1
  40. package/dist/server-utils.prompts.js +77 -24
  41. package/dist/server-utils.prompts.js.map +1 -1
  42. package/dist/server-utils.prompts.test.js +97 -1
  43. package/dist/server-utils.prompts.test.js.map +1 -1
  44. package/dist/server-utils.test.js +582 -5
  45. package/dist/server-utils.test.js.map +1 -1
  46. package/dist/types.d.ts +58 -0
  47. package/dist/types.d.ts.map +1 -1
  48. package/package.json +1 -1
@@ -2,13 +2,14 @@ import { spawn } from "node:child_process";
2
2
  import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { afterEach, describe, expect, it } from "vitest";
6
- import { ensureLocalCliCredentialShimsInPath, ensureRudderCliInPath, loadAgentInstructionsPrefix, prepareAgentInstructionRuntimeContext, renderTemplate, resolveLocalOperatorHome, RUDDER_AGENT_HEARTBEAT_INSTRUCTION, RUDDER_AGENT_OPERATING_CONTRACT, runChildProcess, selectPromptTemplate, shouldIncludeRuntimeHeartbeatInstructions, syncLocalCliCredentialHomeEntries, } from "./server-utils.js";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { cleanupLegacyRudderDocsManagedEntry, createOperatorInterruptAbortReason, ensureLocalCliCredentialShimsInPath, ensureRudderCliInPath, filterRudderDesiredSkillsForBrowserCapability, loadAgentInstructionsPrefix, prepareAgentInstructionRuntimeContext, renderTemplate, resolveDesktopCliSpawnTarget, resolveLocalOperatorHome, resolveRudderCliShimTarget, resolveRudderDesiredSkillNames, RUDDER_AGENT_HEARTBEAT_INSTRUCTION, RUDDER_AGENT_OPERATING_CONTRACT, runChildProcess, selectPromptTemplate, shouldIncludeRuntimeHeartbeatInstructions, syncLocalCliCredentialHomeEntries, } from "./server-utils.js";
7
7
  const ORIGINAL_HOME = process.env.HOME;
8
8
  const ORIGINAL_RUDDER_OPERATOR_HOME = process.env.RUDDER_OPERATOR_HOME;
9
9
  const ORIGINAL_ZDOTDIR = process.env.ZDOTDIR;
10
10
  const ORIGINAL_GIT_AUTHOR_EMAIL = process.env.GIT_AUTHOR_EMAIL;
11
11
  const ORIGINAL_GIT_COMMITTER_EMAIL = process.env.GIT_COMMITTER_EMAIL;
12
+ const ORIGINAL_RUDDER_DESKTOP_CLI_ENTRY = process.env.RUDDER_DESKTOP_CLI_ENTRY;
12
13
  afterEach(() => {
13
14
  if (ORIGINAL_HOME === undefined)
14
15
  delete process.env.HOME;
@@ -30,6 +31,10 @@ afterEach(() => {
30
31
  delete process.env.GIT_COMMITTER_EMAIL;
31
32
  else
32
33
  process.env.GIT_COMMITTER_EMAIL = ORIGINAL_GIT_COMMITTER_EMAIL;
34
+ if (ORIGINAL_RUDDER_DESKTOP_CLI_ENTRY === undefined)
35
+ delete process.env.RUDDER_DESKTOP_CLI_ENTRY;
36
+ else
37
+ process.env.RUDDER_DESKTOP_CLI_ENTRY = ORIGINAL_RUDDER_DESKTOP_CLI_ENTRY;
33
38
  });
34
39
  function readPathValue(env) {
35
40
  return env.PATH ?? env.Path ?? "";
@@ -37,7 +42,393 @@ function readPathValue(env) {
37
42
  function shimName() {
38
43
  return process.platform === "win32" ? "rudder.cmd" : "rudder";
39
44
  }
45
+ describe("cleanupLegacyRudderDocsManagedEntry", () => {
46
+ it("is exposed by the shared runtime utilities", () => {
47
+ expect(cleanupLegacyRudderDocsManagedEntry).toBeTypeOf("function");
48
+ });
49
+ it("removes the dangling legacy symlink derived from the selected canonical Rudder Docs source", async () => {
50
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-docs-legacy-symlink-"));
51
+ const skillsHome = path.join(root, "managed-skills");
52
+ const canonicalSource = path.join(root, "bundled-skills", "rudder-docs");
53
+ const legacySource = path.join(root, "bundled-skills", "rudder");
54
+ const legacyTarget = path.join(skillsHome, "rudder");
55
+ try {
56
+ await fs.mkdir(skillsHome, { recursive: true });
57
+ await fs.mkdir(canonicalSource, { recursive: true });
58
+ await fs.symlink(legacySource, legacyTarget);
59
+ const result = await cleanupLegacyRudderDocsManagedEntry(skillsHome, [{
60
+ key: "rudder/rudder-docs",
61
+ runtimeName: "rudder-docs",
62
+ source: canonicalSource,
63
+ }]);
64
+ expect(result).toEqual({
65
+ state: "removed",
66
+ targetPath: legacyTarget,
67
+ legacySourcePath: legacySource,
68
+ kind: "symlink",
69
+ });
70
+ await expect(fs.lstat(legacyTarget)).rejects.toMatchObject({ code: "ENOENT" });
71
+ }
72
+ finally {
73
+ await fs.rm(root, { recursive: true, force: true });
74
+ }
75
+ });
76
+ it("recognizes the canonical bundled selection key for a dangling legacy symlink", async () => {
77
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-docs-bundled-legacy-symlink-"));
78
+ const skillsHome = path.join(root, "managed-skills");
79
+ const canonicalSource = path.join(root, "bundled-skills", "rudder-docs");
80
+ const legacySource = path.join(root, "bundled-skills", "rudder");
81
+ const legacyTarget = path.join(skillsHome, "rudder");
82
+ try {
83
+ await fs.mkdir(skillsHome, { recursive: true });
84
+ await fs.mkdir(canonicalSource, { recursive: true });
85
+ await fs.symlink(legacySource, legacyTarget);
86
+ const result = await cleanupLegacyRudderDocsManagedEntry(skillsHome, [{
87
+ key: "bundled:rudder/rudder-docs",
88
+ runtimeName: "rudder-docs",
89
+ source: canonicalSource,
90
+ }]);
91
+ expect(result.state).toBe("removed");
92
+ await expect(fs.lstat(legacyTarget)).rejects.toMatchObject({ code: "ENOENT" });
93
+ }
94
+ finally {
95
+ await fs.rm(root, { recursive: true, force: true });
96
+ }
97
+ });
98
+ it("preserves an exact legacy-source symlink when its target still exists", async () => {
99
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-docs-live-legacy-symlink-"));
100
+ const skillsHome = path.join(root, "managed-skills");
101
+ const canonicalSource = path.join(root, "bundled-skills", "rudder-docs");
102
+ const legacySource = path.join(root, "bundled-skills", "rudder");
103
+ const legacyTarget = path.join(skillsHome, "rudder");
104
+ try {
105
+ await fs.mkdir(skillsHome, { recursive: true });
106
+ await fs.mkdir(canonicalSource, { recursive: true });
107
+ await fs.mkdir(legacySource, { recursive: true });
108
+ await fs.symlink(legacySource, legacyTarget);
109
+ const result = await cleanupLegacyRudderDocsManagedEntry(skillsHome, [{
110
+ key: "rudder/rudder-docs",
111
+ runtimeName: "rudder-docs",
112
+ source: canonicalSource,
113
+ }]);
114
+ expect(result.state).toBe("collision");
115
+ expect(result.kind).toBe("symlink");
116
+ expect(await fs.realpath(legacyTarget)).toBe(await fs.realpath(legacySource));
117
+ }
118
+ finally {
119
+ await fs.rm(root, { recursive: true, force: true });
120
+ }
121
+ });
122
+ it("preserves a replacement swapped in after initial symlink validation", async () => {
123
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-docs-symlink-swap-"));
124
+ const skillsHome = path.join(root, "managed-skills");
125
+ const canonicalSource = path.join(root, "bundled-skills", "rudder-docs");
126
+ const legacySource = path.join(root, "bundled-skills", "rudder");
127
+ const legacyTarget = path.join(skillsHome, "rudder");
128
+ let statSpy = null;
129
+ try {
130
+ await fs.mkdir(skillsHome, { recursive: true });
131
+ await fs.mkdir(canonicalSource, { recursive: true });
132
+ await fs.symlink(legacySource, legacyTarget);
133
+ statSpy = vi.spyOn(fs, "stat").mockImplementationOnce(async () => {
134
+ await fs.unlink(legacyTarget);
135
+ await fs.writeFile(legacyTarget, "replacement\n", "utf8");
136
+ throw Object.assign(new Error("legacy source missing"), { code: "ENOENT" });
137
+ });
138
+ const result = await cleanupLegacyRudderDocsManagedEntry(skillsHome, [{
139
+ key: "rudder/rudder-docs",
140
+ runtimeName: "rudder-docs",
141
+ source: canonicalSource,
142
+ }]);
143
+ expect(result.state).toBe("collision");
144
+ await expect(fs.readFile(legacyTarget, "utf8")).resolves.toBe("replacement\n");
145
+ }
146
+ finally {
147
+ statSpy?.mockRestore();
148
+ await fs.rm(root, { recursive: true, force: true });
149
+ }
150
+ });
151
+ it("removes a legacy materialized directory only when its Rudder provenance matches the derived source", async () => {
152
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-docs-legacy-materialized-"));
153
+ const skillsHome = path.join(root, "managed-skills");
154
+ const canonicalSource = path.join(root, "bundled-skills", "rudder-docs");
155
+ const legacySource = path.join(root, "bundled-skills", "rudder");
156
+ const legacyTarget = path.join(skillsHome, "rudder");
157
+ try {
158
+ await fs.mkdir(canonicalSource, { recursive: true });
159
+ await fs.mkdir(path.join(legacyTarget, ".rudder"), { recursive: true });
160
+ await fs.writeFile(path.join(legacyTarget, ".rudder", "materialized-skill.json"), JSON.stringify({ sourcePath: legacySource }), "utf8");
161
+ const result = await cleanupLegacyRudderDocsManagedEntry(skillsHome, [{
162
+ key: "rudder/rudder-docs",
163
+ runtimeName: "rudder-docs",
164
+ source: canonicalSource,
165
+ }]);
166
+ expect(result).toEqual({
167
+ state: "removed",
168
+ targetPath: legacyTarget,
169
+ legacySourcePath: legacySource,
170
+ kind: "directory",
171
+ });
172
+ await expect(fs.lstat(legacyTarget)).rejects.toMatchObject({ code: "ENOENT" });
173
+ }
174
+ finally {
175
+ await fs.rm(root, { recursive: true, force: true });
176
+ }
177
+ });
178
+ it("preserves a materialized directory replacement with matching provenance but a different inode", async () => {
179
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-docs-directory-swap-"));
180
+ const skillsHome = path.join(root, "managed-skills");
181
+ const canonicalSource = path.join(root, "bundled-skills", "rudder-docs");
182
+ const legacySource = path.join(root, "bundled-skills", "rudder");
183
+ const legacyTarget = path.join(skillsHome, "rudder");
184
+ const displacedTarget = path.join(skillsHome, "rudder-original");
185
+ const manifestPath = path.join(legacyTarget, ".rudder", "materialized-skill.json");
186
+ const replacementMarker = path.join(legacyTarget, "replacement.txt");
187
+ let lstatSpy = null;
188
+ try {
189
+ await fs.mkdir(canonicalSource, { recursive: true });
190
+ await fs.mkdir(path.dirname(manifestPath), { recursive: true });
191
+ await fs.writeFile(manifestPath, JSON.stringify({ sourcePath: legacySource }), "utf8");
192
+ const originalLstat = fs.lstat.bind(fs);
193
+ lstatSpy = vi.spyOn(fs, "lstat").mockImplementationOnce(async () => {
194
+ const originalStats = await originalLstat(legacyTarget);
195
+ await fs.rename(legacyTarget, displacedTarget);
196
+ await fs.mkdir(path.dirname(manifestPath), { recursive: true });
197
+ await fs.writeFile(manifestPath, JSON.stringify({ sourcePath: legacySource }), "utf8");
198
+ await fs.writeFile(replacementMarker, "replacement\n", "utf8");
199
+ return originalStats;
200
+ });
201
+ const result = await cleanupLegacyRudderDocsManagedEntry(skillsHome, [{
202
+ key: "rudder/rudder-docs",
203
+ runtimeName: "rudder-docs",
204
+ source: canonicalSource,
205
+ }]);
206
+ expect(result.state).toBe("collision");
207
+ await expect(fs.readFile(replacementMarker, "utf8")).resolves.toBe("replacement\n");
208
+ }
209
+ finally {
210
+ lstatSpy?.mockRestore();
211
+ await fs.rm(root, { recursive: true, force: true });
212
+ }
213
+ });
214
+ it("reports a failed state when unlinking the proven dangling symlink fails", async () => {
215
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-docs-unlink-failure-"));
216
+ const skillsHome = path.join(root, "managed-skills");
217
+ const canonicalSource = path.join(root, "bundled-skills", "rudder-docs");
218
+ const legacySource = path.join(root, "bundled-skills", "rudder");
219
+ const legacyTarget = path.join(skillsHome, "rudder");
220
+ let unlinkSpy = null;
221
+ try {
222
+ await fs.mkdir(skillsHome, { recursive: true });
223
+ await fs.mkdir(canonicalSource, { recursive: true });
224
+ await fs.symlink(legacySource, legacyTarget);
225
+ unlinkSpy = vi.spyOn(fs, "unlink").mockRejectedValueOnce(new Error("simulated unlink failure"));
226
+ const result = await cleanupLegacyRudderDocsManagedEntry(skillsHome, [{
227
+ key: "rudder/rudder-docs",
228
+ runtimeName: "rudder-docs",
229
+ source: canonicalSource,
230
+ }]);
231
+ expect(result).toMatchObject({
232
+ state: "failed",
233
+ targetPath: legacyTarget,
234
+ kind: "symlink",
235
+ detail: "unlink failed: simulated unlink failure",
236
+ });
237
+ expect((await fs.lstat(legacyTarget)).isSymbolicLink()).toBe(true);
238
+ }
239
+ finally {
240
+ unlinkSpy?.mockRestore();
241
+ await fs.rm(root, { recursive: true, force: true });
242
+ }
243
+ });
244
+ it("reports a failed state when removing the proven materialized directory fails", async () => {
245
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-docs-rm-failure-"));
246
+ const skillsHome = path.join(root, "managed-skills");
247
+ const canonicalSource = path.join(root, "bundled-skills", "rudder-docs");
248
+ const legacySource = path.join(root, "bundled-skills", "rudder");
249
+ const legacyTarget = path.join(skillsHome, "rudder");
250
+ const manifestPath = path.join(legacyTarget, ".rudder", "materialized-skill.json");
251
+ let rmSpy = null;
252
+ try {
253
+ await fs.mkdir(canonicalSource, { recursive: true });
254
+ await fs.mkdir(path.dirname(manifestPath), { recursive: true });
255
+ await fs.writeFile(manifestPath, JSON.stringify({ sourcePath: legacySource }), "utf8");
256
+ rmSpy = vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("simulated rm failure"));
257
+ const result = await cleanupLegacyRudderDocsManagedEntry(skillsHome, [{
258
+ key: "rudder/rudder-docs",
259
+ runtimeName: "rudder-docs",
260
+ source: canonicalSource,
261
+ }]);
262
+ expect(result).toMatchObject({
263
+ state: "failed",
264
+ targetPath: legacyTarget,
265
+ kind: "directory",
266
+ detail: "recursive removal failed: simulated rm failure",
267
+ });
268
+ expect((await fs.lstat(legacyTarget)).isDirectory()).toBe(true);
269
+ }
270
+ finally {
271
+ rmSpy?.mockRestore();
272
+ await fs.rm(root, { recursive: true, force: true });
273
+ }
274
+ });
275
+ it("preserves an unrecognized symlink and reports an ownership collision", async () => {
276
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-docs-legacy-symlink-collision-"));
277
+ const skillsHome = path.join(root, "managed-skills");
278
+ const canonicalSource = path.join(root, "bundled-skills", "rudder-docs");
279
+ const unrelatedSource = path.join(root, "user-skills", "rudder");
280
+ const legacyTarget = path.join(skillsHome, "rudder");
281
+ try {
282
+ await fs.mkdir(skillsHome, { recursive: true });
283
+ await fs.mkdir(canonicalSource, { recursive: true });
284
+ await fs.symlink(unrelatedSource, legacyTarget);
285
+ const result = await cleanupLegacyRudderDocsManagedEntry(skillsHome, [{
286
+ key: "rudder/rudder-docs",
287
+ runtimeName: "rudder-docs",
288
+ source: canonicalSource,
289
+ }]);
290
+ expect(result.state).toBe("collision");
291
+ expect(result.kind).toBe("symlink");
292
+ expect((await fs.lstat(legacyTarget)).isSymbolicLink()).toBe(true);
293
+ expect(await fs.readlink(legacyTarget)).toBe(unrelatedSource);
294
+ }
295
+ finally {
296
+ await fs.rm(root, { recursive: true, force: true });
297
+ }
298
+ });
299
+ it("preserves a regular directory without matching Rudder provenance", async () => {
300
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-docs-legacy-dir-collision-"));
301
+ const skillsHome = path.join(root, "managed-skills");
302
+ const canonicalSource = path.join(root, "bundled-skills", "rudder-docs");
303
+ const legacyTarget = path.join(skillsHome, "rudder");
304
+ const marker = path.join(legacyTarget, "user-owned.txt");
305
+ try {
306
+ await fs.mkdir(canonicalSource, { recursive: true });
307
+ await fs.mkdir(legacyTarget, { recursive: true });
308
+ await fs.writeFile(marker, "keep me\n", "utf8");
309
+ const result = await cleanupLegacyRudderDocsManagedEntry(skillsHome, [{
310
+ key: "rudder/rudder-docs",
311
+ runtimeName: "rudder-docs",
312
+ source: canonicalSource,
313
+ }]);
314
+ expect(result.state).toBe("collision");
315
+ expect(result.kind).toBe("directory");
316
+ await expect(fs.readFile(marker, "utf8")).resolves.toBe("keep me\n");
317
+ }
318
+ finally {
319
+ await fs.rm(root, { recursive: true, force: true });
320
+ }
321
+ });
322
+ it("preserves a regular file at the legacy child path", async () => {
323
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-docs-legacy-file-collision-"));
324
+ const skillsHome = path.join(root, "managed-skills");
325
+ const canonicalSource = path.join(root, "bundled-skills", "rudder-docs");
326
+ const legacyTarget = path.join(skillsHome, "rudder");
327
+ try {
328
+ await fs.mkdir(canonicalSource, { recursive: true });
329
+ await fs.mkdir(skillsHome, { recursive: true });
330
+ await fs.writeFile(legacyTarget, "user-owned\n", "utf8");
331
+ const result = await cleanupLegacyRudderDocsManagedEntry(skillsHome, [{
332
+ key: "rudder/rudder-docs",
333
+ runtimeName: "rudder-docs",
334
+ source: canonicalSource,
335
+ }]);
336
+ expect(result.state).toBe("collision");
337
+ expect(result.kind).toBe("file");
338
+ await expect(fs.readFile(legacyTarget, "utf8")).resolves.toBe("user-owned\n");
339
+ }
340
+ finally {
341
+ await fs.rm(root, { recursive: true, force: true });
342
+ }
343
+ });
344
+ it("does not trust a selected user skill merely because its runtime name is rudder-docs", async () => {
345
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-docs-user-skill-"));
346
+ const skillsHome = path.join(root, "managed-skills");
347
+ const userSource = path.join(root, "user-skills", "rudder-docs");
348
+ const userSibling = path.join(root, "user-skills", "rudder");
349
+ const legacyTarget = path.join(skillsHome, "rudder");
350
+ try {
351
+ await fs.mkdir(skillsHome, { recursive: true });
352
+ await fs.mkdir(userSource, { recursive: true });
353
+ await fs.symlink(userSibling, legacyTarget);
354
+ const result = await cleanupLegacyRudderDocsManagedEntry(skillsHome, [{
355
+ key: "user/rudder-docs",
356
+ runtimeName: "rudder-docs",
357
+ source: userSource,
358
+ }]);
359
+ expect(result).toEqual({
360
+ state: "not_applicable",
361
+ targetPath: legacyTarget,
362
+ legacySourcePath: null,
363
+ kind: null,
364
+ });
365
+ expect((await fs.lstat(legacyTarget)).isSymbolicLink()).toBe(true);
366
+ }
367
+ finally {
368
+ await fs.rm(root, { recursive: true, force: true });
369
+ }
370
+ });
371
+ });
372
+ describe("filterRudderDesiredSkillsForBrowserCapability", () => {
373
+ const available = [
374
+ { key: "bundled:rudder/browser", runtimeName: "browser" },
375
+ { key: "org:keep-skill", runtimeName: "keep-skill" },
376
+ ];
377
+ const desired = available.map((entry) => entry.key);
378
+ it("removes only Browser from the effective desired set after preflight disables it", () => {
379
+ expect(filterRudderDesiredSkillsForBrowserCapability(available, desired, false)).toEqual(["org:keep-skill"]);
380
+ });
381
+ it("preserves the desired set when Browser remains available", () => {
382
+ expect(filterRudderDesiredSkillsForBrowserCapability(available, desired, true)).toEqual(desired);
383
+ });
384
+ });
385
+ describe("resolveRudderDesiredSkillNames", () => {
386
+ it("preserves an exact nested Rudder repository skill key", () => {
387
+ const nestedRepositorySkill = {
388
+ key: "rudder/rudder/community-helper",
389
+ runtimeName: "community-helper",
390
+ };
391
+ expect(resolveRudderDesiredSkillNames({
392
+ rudderSkillSync: {
393
+ desiredSkills: [nestedRepositorySkill.key],
394
+ },
395
+ }, [nestedRepositorySkill])).toEqual([nestedRepositorySkill.key]);
396
+ });
397
+ it("normalizes legacy Rudder Docs inputs and emits one canonical runtime key", () => {
398
+ const available = [
399
+ { key: "bundled:rudder/rudder-docs", runtimeName: "rudder-docs" },
400
+ ];
401
+ expect(resolveRudderDesiredSkillNames({
402
+ rudderSkillSync: {
403
+ desiredSkills: [
404
+ "rudder",
405
+ "rudder/rudder",
406
+ "bundled:rudder/rudder",
407
+ "bundled:rudder/rudder-docs",
408
+ ],
409
+ },
410
+ }, available)).toEqual(["bundled:rudder/rudder-docs"]);
411
+ });
412
+ });
40
413
  describe("ensureRudderCliInPath", () => {
414
+ it("runs the packaged Windows CLI in Electron's Node mode so stdio remains available", () => {
415
+ const cliEntry = String.raw `C:\Program Files\Rudder\resources\server-package\desktop-cli.js`;
416
+ const cliRunner = String.raw `C:\Program Files\Rudder\resources\server-package\desktop-cli-runner.js`;
417
+ const executable = String.raw `C:\Program Files\Rudder\Rudder.exe`;
418
+ expect(resolveDesktopCliSpawnTarget(cliEntry, executable, "win32", cliRunner)).toEqual({
419
+ command: executable,
420
+ args: [cliRunner],
421
+ env: { ELECTRON_RUN_AS_NODE: "1" },
422
+ });
423
+ expect(resolveDesktopCliSpawnTarget(cliEntry, executable, "darwin")).toEqual({
424
+ command: executable,
425
+ args: ["--desktop-cli"],
426
+ });
427
+ expect(resolveDesktopCliSpawnTarget(cliEntry, executable, "win32")).toEqual({
428
+ command: executable,
429
+ args: ["--desktop-cli"],
430
+ });
431
+ });
41
432
  it("prefers the current source CLI shim over an existing rudder binary on PATH", async () => {
42
433
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-cli-source-shim-"));
43
434
  const moduleDir = path.join(root, "packages", "agent-runtime-utils", "src");
@@ -88,6 +479,61 @@ describe("ensureRudderCliInPath", () => {
88
479
  await fs.rm(root, { recursive: true, force: true });
89
480
  }
90
481
  });
482
+ it("uses the Desktop CLI entry when the server runs from an external runtime cache", async () => {
483
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-cli-external-runtime-shim-"));
484
+ const runtimeModuleDir = path.join(root, "runtimes", "0.4.6", "node_modules", "@rudderhq", "agent-runtime-utils", "dist");
485
+ const desktopCli = path.join(root, "Desktop.app", "Contents", "Resources", "server-package", "desktop-cli.js");
486
+ const desktopExecutable = process.platform === "darwin"
487
+ ? path.join(root, "Desktop.app", "Contents", "MacOS", "Rudder")
488
+ : path.join(root, "Desktop.app", "Contents", process.platform === "win32" ? "Rudder.exe" : "Rudder");
489
+ const desktopManifest = path.join(path.dirname(desktopCli), "rudder-cli-package.json");
490
+ const staleBinDir = path.join(root, "stale-bin");
491
+ const staleRudder = path.join(staleBinDir, shimName());
492
+ try {
493
+ await fs.mkdir(runtimeModuleDir, { recursive: true });
494
+ await fs.mkdir(path.dirname(desktopCli), { recursive: true });
495
+ await fs.mkdir(path.dirname(desktopExecutable), { recursive: true });
496
+ await fs.mkdir(staleBinDir, { recursive: true });
497
+ await fs.writeFile(desktopCli, "console.log('fresh desktop cli');\n", "utf8");
498
+ await fs.writeFile(path.join(path.dirname(desktopCli), "desktop-cli-runner.js"), "console.log('desktop cli runner');\n", "utf8");
499
+ await fs.writeFile(desktopExecutable, "desktop executable\n", "utf8");
500
+ await fs.writeFile(desktopManifest, JSON.stringify({ name: "@rudderhq/cli", version: "0.4.6" }), "utf8");
501
+ await fs.writeFile(staleRudder, process.platform === "win32" ? "@echo off\r\necho stale\r\n" : "#!/bin/sh\necho stale\n", "utf8");
502
+ await fs.chmod(staleRudder, 0o755);
503
+ process.env.RUDDER_DESKTOP_CLI_ENTRY = desktopCli;
504
+ const target = await resolveRudderCliShimTarget(runtimeModuleDir);
505
+ const env = await ensureRudderCliInPath(runtimeModuleDir, { PATH: staleBinDir });
506
+ const firstPathEntry = readPathValue(env).split(path.delimiter)[0];
507
+ const shim = await fs.readFile(path.join(firstPathEntry, shimName()), "utf8");
508
+ expect(target).toMatchObject(process.platform === "win32"
509
+ ? {
510
+ command: desktopExecutable,
511
+ args: [path.join(path.dirname(desktopCli), "desktop-cli-runner.js")],
512
+ env: { ELECTRON_RUN_AS_NODE: "1" },
513
+ provenance: "desktop_bundle",
514
+ version: "0.4.6",
515
+ }
516
+ : {
517
+ command: desktopExecutable,
518
+ args: ["--desktop-cli"],
519
+ provenance: "desktop_bundle",
520
+ version: "0.4.6",
521
+ });
522
+ expect(firstPathEntry).not.toBe(staleBinDir);
523
+ expect(shim).toContain(desktopExecutable);
524
+ if (process.platform === "win32") {
525
+ expect(shim).toContain("ELECTRON_RUN_AS_NODE=1");
526
+ expect(shim).toContain("desktop-cli-runner.js");
527
+ }
528
+ else {
529
+ expect(shim).toContain("--desktop-cli");
530
+ expect(shim).not.toContain(desktopCli);
531
+ }
532
+ }
533
+ finally {
534
+ await fs.rm(root, { recursive: true, force: true });
535
+ }
536
+ });
91
537
  });
92
538
  describe("selectPromptTemplate", () => {
93
539
  it("keeps issue assignment prompts neutral for non-code work", () => {
@@ -711,6 +1157,56 @@ describe("loadAgentInstructionsPrefix", () => {
711
1157
  });
712
1158
  });
713
1159
  describe("runChildProcess", () => {
1160
+ function isPidAlive(pid) {
1161
+ try {
1162
+ process.kill(pid, 0);
1163
+ return true;
1164
+ }
1165
+ catch (error) {
1166
+ const code = error instanceof Error && "code" in error ? error.code : null;
1167
+ return code === "EPERM";
1168
+ }
1169
+ }
1170
+ async function waitForPidExit(pid, timeoutMs = 2_000) {
1171
+ const deadline = Date.now() + timeoutMs;
1172
+ while (Date.now() < deadline) {
1173
+ if (!isPidAlive(pid))
1174
+ return;
1175
+ await new Promise((resolve) => setTimeout(resolve, 20));
1176
+ }
1177
+ throw new Error(`process ${pid} remained alive after ${timeoutMs}ms`);
1178
+ }
1179
+ function forceKillProcessGroup(pid) {
1180
+ if (process.platform === "win32" || pid === null)
1181
+ return;
1182
+ try {
1183
+ process.kill(-pid, "SIGKILL");
1184
+ }
1185
+ catch {
1186
+ // The group already exited.
1187
+ }
1188
+ }
1189
+ it("does not expose the Desktop CLI entry to provider child processes", async () => {
1190
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-run-child-process-desktop-cli-env-"));
1191
+ const capturePath = path.join(root, "env.json");
1192
+ const scriptPath = path.join(root, "capture-env.mjs");
1193
+ await fs.writeFile(scriptPath, "import fs from 'node:fs'; fs.writeFileSync(process.argv[2], JSON.stringify(process.env.RUDDER_DESKTOP_CLI_ENTRY ?? null));\n", "utf8");
1194
+ process.env.RUDDER_DESKTOP_CLI_ENTRY = "/private/Desktop.app/desktop-cli.js";
1195
+ try {
1196
+ const result = await runChildProcess("run-child-process-desktop-cli-env", process.execPath, [scriptPath, capturePath], {
1197
+ cwd: root,
1198
+ env: {},
1199
+ timeoutSec: 5,
1200
+ graceSec: 1,
1201
+ onLog: async () => { },
1202
+ });
1203
+ expect(result.exitCode).toBe(0);
1204
+ expect(JSON.parse(await fs.readFile(capturePath, "utf8"))).toBeNull();
1205
+ }
1206
+ finally {
1207
+ await fs.rm(root, { recursive: true, force: true });
1208
+ }
1209
+ });
714
1210
  it("preserves explicit blank Git identity env overrides", async () => {
715
1211
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-run-child-process-git-env-"));
716
1212
  const capturePath = path.join(root, "env.json");
@@ -809,12 +1305,14 @@ describe("runChildProcess", () => {
809
1305
  await fs.rm(root, { recursive: true, force: true });
810
1306
  }
811
1307
  });
812
- it("forces SIGKILL after the grace period when an aborted child ignores SIGTERM", async () => {
1308
+ it.runIf(process.platform !== "win32")("keeps the configured grace for a generic abort without an operator reason", async () => {
813
1309
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-run-child-process-abort-"));
814
1310
  const scriptPath = path.join(root, "ignore-sigterm.mjs");
815
1311
  let spawnedPid = null;
1312
+ let abortedAt = 0;
816
1313
  await fs.writeFile(scriptPath, [
817
1314
  "process.on('SIGTERM', () => {});",
1315
+ "console.log('ready');",
818
1316
  "setInterval(() => {}, 1000);",
819
1317
  ].join("\n"), "utf8");
820
1318
  const controller = new AbortController();
@@ -828,18 +1326,97 @@ describe("runChildProcess", () => {
828
1326
  abortSignal: controller.signal,
829
1327
  onSpawn: async ({ pid }) => {
830
1328
  spawnedPid = pid;
831
- setTimeout(() => controller.abort(), 50);
832
1329
  },
833
- onLog: async () => { },
1330
+ onLog: async (_stream, chunk) => {
1331
+ if (chunk.includes("ready") && abortedAt === 0) {
1332
+ abortedAt = Date.now();
1333
+ controller.abort();
1334
+ }
1335
+ },
834
1336
  });
835
1337
  expect(result.signal).toBe("SIGTERM");
836
1338
  expect(Date.now() - startedAt).toBeLessThan(5_000);
1339
+ expect(abortedAt).toBeGreaterThan(0);
1340
+ expect(Date.now() - abortedAt).toBeGreaterThanOrEqual(800);
837
1341
  expect(spawnedPid).not.toBeNull();
838
1342
  await new Promise((resolve) => setTimeout(resolve, 50));
839
1343
  expect(() => process.kill(spawnedPid, 0)).toThrow();
840
1344
  }
841
1345
  finally {
842
1346
  controller.abort();
1347
+ forceKillProcessGroup(spawnedPid);
1348
+ await fs.rm(root, { recursive: true, force: true });
1349
+ }
1350
+ }, 10_000);
1351
+ it.runIf(process.platform !== "win32")("kills a stubborn parent and grandchild by the operator hard deadline without forwarding post-abort output", async () => {
1352
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rudder-run-child-process-group-abort-"));
1353
+ const parentScriptPath = path.join(root, "stubborn-parent.mjs");
1354
+ const grandchildScriptPath = path.join(root, "stubborn-grandchild.mjs");
1355
+ const grandchildPidPath = path.join(root, "grandchild.pid");
1356
+ const controller = new AbortController();
1357
+ const liveLogs = [];
1358
+ let parentPid = null;
1359
+ let grandchildPid = null;
1360
+ let abortedAt = 0;
1361
+ await fs.writeFile(grandchildScriptPath, [
1362
+ "process.on('SIGTERM', () => console.log('grandchild-after-abort'));",
1363
+ "console.log('grandchild-ready');",
1364
+ "setInterval(() => console.log('grandchild-tick'), 25);",
1365
+ ].join("\n"), "utf8");
1366
+ await fs.writeFile(parentScriptPath, [
1367
+ "import { spawn } from 'node:child_process';",
1368
+ "import fs from 'node:fs';",
1369
+ "process.on('SIGTERM', () => console.log('parent-after-abort'));",
1370
+ `const grandchild = spawn(process.execPath, [${JSON.stringify(grandchildScriptPath)}], { stdio: ['ignore', 'inherit', 'inherit'] });`,
1371
+ "fs.writeFileSync(process.argv[2], String(grandchild.pid));",
1372
+ "console.log('parent-ready');",
1373
+ "setInterval(() => console.log('parent-tick'), 25);",
1374
+ ].join("\n"), "utf8");
1375
+ try {
1376
+ const result = await runChildProcess("run-child-process-group-operator-abort", process.execPath, [parentScriptPath, grandchildPidPath], {
1377
+ cwd: root,
1378
+ env: {},
1379
+ timeoutSec: 10,
1380
+ graceSec: 5,
1381
+ abortSignal: controller.signal,
1382
+ onSpawn: async ({ pid }) => {
1383
+ parentPid = pid;
1384
+ },
1385
+ onLog: async (_stream, chunk) => {
1386
+ liveLogs.push(chunk);
1387
+ if (abortedAt === 0 && chunk.includes("grandchild-ready")) {
1388
+ grandchildPid = Number(await fs.readFile(grandchildPidPath, "utf8"));
1389
+ abortedAt = Date.now();
1390
+ controller.abort(createOperatorInterruptAbortReason(250));
1391
+ }
1392
+ },
1393
+ });
1394
+ expect(result.signal).toBe("SIGTERM");
1395
+ expect(abortedAt).toBeGreaterThan(0);
1396
+ expect(Date.now() - abortedAt).toBeGreaterThanOrEqual(150);
1397
+ expect(Date.now() - abortedAt).toBeLessThan(1_500);
1398
+ expect(result.stdout).toContain("parent-after-abort");
1399
+ expect(result.stdout).toContain("grandchild-after-abort");
1400
+ expect(liveLogs.join("")).not.toContain("parent-after-abort");
1401
+ expect(liveLogs.join("")).not.toContain("grandchild-after-abort");
1402
+ expect(parentPid).not.toBeNull();
1403
+ expect(grandchildPid).not.toBeNull();
1404
+ await waitForPidExit(parentPid);
1405
+ await waitForPidExit(grandchildPid);
1406
+ }
1407
+ finally {
1408
+ if (!controller.signal.aborted) {
1409
+ controller.abort(createOperatorInterruptAbortReason(1));
1410
+ }
1411
+ forceKillProcessGroup(parentPid);
1412
+ if (grandchildPid !== null && isPidAlive(grandchildPid)) {
1413
+ try {
1414
+ process.kill(grandchildPid, "SIGKILL");
1415
+ }
1416
+ catch {
1417
+ // The process already exited.
1418
+ }
1419
+ }
843
1420
  await fs.rm(root, { recursive: true, force: true });
844
1421
  }
845
1422
  }, 10_000);