@rudderhq/agent-runtime-utils 0.2.0-canary.4 → 0.2.0-canary.40

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.
@@ -27,6 +27,38 @@ const RUDDER_SKILL_ROOT_RELATIVE_CANDIDATES = [
27
27
  "../../skills",
28
28
  "../../../../../server/resources/bundled-skills",
29
29
  ];
30
+ const DEFAULT_LOCAL_CLI_CREDENTIAL_HOME_ENTRIES = [
31
+ ".aws",
32
+ ".azure",
33
+ ".config/gh",
34
+ ".config/gcloud",
35
+ ".config/op",
36
+ ".config/vercel",
37
+ ".config/configstore",
38
+ ".docker",
39
+ ".fly",
40
+ ".git-credentials",
41
+ ".gnupg",
42
+ ".kube",
43
+ ".netrc",
44
+ ".npmrc",
45
+ ".ssh",
46
+ ".vercel",
47
+ "Library/Application Support/gh",
48
+ "Library/Application Support/com.heroku.cli",
49
+ ];
50
+ const DEFAULT_LOCAL_CLI_OPERATOR_HOME_SHIM_COMMANDS = [
51
+ {
52
+ command: "gh",
53
+ authCheckArgs: ["auth", "status"],
54
+ credentialEntries: [".config/gh", "Library/Application Support/gh"],
55
+ },
56
+ {
57
+ command: "vercel",
58
+ authCheckArgs: ["whoami"],
59
+ credentialEntries: [".config/vercel", ".vercel", ".config/configstore"],
60
+ },
61
+ ];
30
62
  function normalizePathSlashes(value) {
31
63
  return value.replaceAll("\\", "/");
32
64
  }
@@ -161,10 +193,68 @@ export function resolvePathValue(obj, dottedPath) {
161
193
  export function renderTemplate(template, data) {
162
194
  return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path));
163
195
  }
196
+ const ISSUE_DOCUMENT_PROMPT_BODY_CHAR_LIMIT = 16_000;
197
+ function truncateIssueDocumentBody(body) {
198
+ if (body.length <= ISSUE_DOCUMENT_PROMPT_BODY_CHAR_LIMIT)
199
+ return body;
200
+ return `${body.slice(0, ISSUE_DOCUMENT_PROMPT_BODY_CHAR_LIMIT).trimEnd()}\n\n[Document truncated in prompt. Fetch the full document with the Rudder CLI.]`;
201
+ }
202
+ function formatDocumentHeading(key, title) {
203
+ const cleanTitle = typeof title === "string" ? title.trim() : "";
204
+ return cleanTitle ? `### ${key} — ${cleanTitle}` : `### ${key}`;
205
+ }
206
+ function readIssueDocumentPromptIssueId(input) {
207
+ const planIssueId = typeof input.planDocument?.issueId === "string" ? input.planDocument.issueId.trim() : "";
208
+ if (planIssueId)
209
+ return planIssueId;
210
+ for (const summary of input.documentSummaries ?? []) {
211
+ const issueId = typeof summary.issueId === "string" ? summary.issueId.trim() : "";
212
+ if (issueId)
213
+ return issueId;
214
+ }
215
+ return "<issue-id>";
216
+ }
217
+ export function buildIssueDocumentsPrompt(input) {
218
+ if (!input)
219
+ return "";
220
+ const sections = [];
221
+ const planKey = input.planDocument?.key?.trim() || input.legacyPlanDocument?.key?.trim() || "plan";
222
+ const planBody = input.planDocument?.body?.trim() || input.legacyPlanDocument?.body?.trim() || "";
223
+ if (planBody) {
224
+ sections.push([
225
+ formatDocumentHeading(planKey, input.planDocument?.title),
226
+ input.legacyPlanDocument ? "Source: legacy `<plan>` block in the issue description." : `Source: issue document \`${planKey}\`.`,
227
+ "",
228
+ truncateIssueDocumentBody(planBody),
229
+ ].join("\n"));
230
+ }
231
+ const otherDocuments = (input.documentSummaries ?? []).filter((doc) => {
232
+ const key = typeof doc.key === "string" ? doc.key.trim() : "";
233
+ return key && key !== planKey;
234
+ });
235
+ if (otherDocuments.length > 0) {
236
+ const issueId = readIssueDocumentPromptIssueId(input);
237
+ sections.push([
238
+ "### Additional Issue Documents",
239
+ ...otherDocuments.map((doc) => {
240
+ const key = doc.key?.trim() || "document";
241
+ const title = doc.title?.trim();
242
+ const revision = typeof doc.latestRevisionNumber === "number" ? `, revision ${doc.latestRevisionNumber}` : "";
243
+ const titlePart = title ? ` — ${title}` : "";
244
+ return `- \`${key}\`${titlePart}${revision}. Fetch with \`rudder issue documents get ${issueId} ${key} --json\`.`;
245
+ }),
246
+ ].join("\n"));
247
+ }
248
+ if (sections.length === 0)
249
+ return "";
250
+ return ["## Issue Documents", ...sections].join("\n\n");
251
+ }
164
252
  // Default prompt templates for different wake sources
165
253
  export const DEFAULT_AGENT_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). Continue your Rudder work.
166
254
 
167
- {{context.rudderWorkspace.orgResourcesPrompt}}`;
255
+ {{context.rudderWorkspace.orgResourcesPrompt}}
256
+
257
+ {{context.issueDocumentsPrompt}}`;
168
258
  export const ISSUE_ASSIGN_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). You have been assigned to work on an issue.
169
259
 
170
260
  {{context.rudderWorkspace.orgResourcesPrompt}}
@@ -179,6 +269,8 @@ export const ISSUE_ASSIGN_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent
179
269
  **Description:**
180
270
  {{issue.description}}
181
271
 
272
+ {{context.issueDocumentsPrompt}}
273
+
182
274
  Your task is to review this issue and begin working on it. Use the available tools to explore the codebase, understand the requirements, and implement a solution.`;
183
275
  export const COMMENT_MENTION_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). You were mentioned in a comment and your attention is needed.
184
276
 
@@ -192,6 +284,8 @@ export const COMMENT_MENTION_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{ag
192
284
  **Issue Description:**
193
285
  {{issue.description}}
194
286
 
287
+ {{context.issueDocumentsPrompt}}
288
+
195
289
  **Comment:**
196
290
  {{comment.body}}
197
291
 
@@ -209,10 +303,31 @@ export const ISSUE_COMMENTED_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{ag
209
303
  **Issue Description:**
210
304
  {{issue.description}}
211
305
 
306
+ {{context.issueDocumentsPrompt}}
307
+
212
308
  **Latest Comment:**
213
309
  {{comment.body}}
214
310
 
215
311
  Review the new comment and continue the issue from the current state. Respond or take action as needed.`;
312
+ export const ISSUE_CHANGES_REQUESTED_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). A reviewer requested changes on an issue you own.
313
+
314
+ {{context.rudderWorkspace.orgResourcesPrompt}}
315
+
316
+ ## Context
317
+
318
+ **Issue:** {{issue.title}}
319
+ **ID:** {{issue.id}}
320
+ **Status:** {{issue.status}}
321
+
322
+ **Issue Description:**
323
+ {{issue.description}}
324
+
325
+ {{context.issueDocumentsPrompt}}
326
+
327
+ **Reviewer Comment:**
328
+ {{comment.body}}
329
+
330
+ Review the requested changes and continue the issue from the current state. Address the reviewer feedback before handing it back for review.`;
216
331
  export const ISSUE_RECOVERY_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). This is a recovery run, not a fresh task.
217
332
 
218
333
  {{context.rudderWorkspace.orgResourcesPrompt}}
@@ -235,6 +350,8 @@ export const ISSUE_RECOVERY_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{age
235
350
  - Description:
236
351
  {{issue.description}}
237
352
 
353
+ {{context.issueDocumentsPrompt}}
354
+
238
355
  Before doing anything else, inspect what the previous run already completed and any side effects it may have caused. Continue the remaining work from the current state. Avoid blindly re-running the whole task.`;
239
356
  export const RECOVERY_PROMPT_TEMPLATE = `You are agent {{agent.id}} ({{agent.name}}). This is a recovery run, not a fresh task.
240
357
 
@@ -272,6 +389,8 @@ Reason: {{context.passiveFollowup.reason}}
272
389
  - Description:
273
390
  {{issue.description}}
274
391
 
392
+ {{context.issueDocumentsPrompt}}
393
+
275
394
  Before changing the issue, inspect the current issue state and any side effects from the previous run. Then do exactly one close-out action: add a progress comment, mark the issue done, block it with a reason, or hand it off explicitly with explanation.`;
276
395
  /**
277
396
  * Selects the base heartbeat prompt template used by runtimes before final prompt assembly.
@@ -283,6 +402,9 @@ Before changing the issue, inspect the current issue state and any side effects
283
402
  * - comment.mention:
284
403
  * "You were mentioned in a comment ..."
285
404
  * Includes issue summary plus mention comment body so the agent can respond without extra fetches.
405
+ * - issue_changes_requested:
406
+ * "A reviewer requested changes on an issue you own ..."
407
+ * Includes issue summary plus reviewer comment body so the assignee can act on feedback immediately.
286
408
  * - issue_commented:
287
409
  * "There is a new comment on an issue you own ..."
288
410
  * Includes issue summary plus the newest comment body so the assignee can continue immediately.
@@ -331,6 +453,9 @@ export function selectPromptTemplate(configuredTemplate, context) {
331
453
  if (wakeReason === "issue_passive_followup") {
332
454
  return ISSUE_PASSIVE_FOLLOWUP_PROMPT_TEMPLATE;
333
455
  }
456
+ if (wakeReason === "issue_changes_requested") {
457
+ return ISSUE_CHANGES_REQUESTED_PROMPT_TEMPLATE;
458
+ }
334
459
  if (wakeSource === "assignment" || wakeReason === "issue_assigned") {
335
460
  return ISSUE_ASSIGN_PROMPT_TEMPLATE;
336
461
  }
@@ -362,10 +487,18 @@ export const RUDDER_AGENT_OPERATING_CONTRACT = [
362
487
  "- Shared organization workspace root lives under `$RUDDER_ORG_WORKSPACE_ROOT`.",
363
488
  "- Shared organization skills live under `$RUDDER_ORG_SKILLS_DIR`.",
364
489
  "- Shared organization plans live under `$RUDDER_ORG_PLANS_DIR`.",
490
+ "- Shared organization artifacts live under `$RUDDER_ORG_ARTIFACTS_DIR`.",
491
+ "- Durable generated outputs such as screenshots, images, mockups, reports, CSVs, handoff logs, and other user-visible files should be written under `$RUDDER_ORG_ARTIFACTS_DIR` when available.",
492
+ "- Use `/tmp` only for transient scratch files and temporary verification artifacts; do not put durable work product there.",
493
+ "- Local trusted runtimes may expose the host operator home as `$RUDDER_OPERATOR_HOME`; use it only when a local skill or script intentionally needs operator-owned desktop app or CLI state. Do not replace `$HOME` with it.",
365
494
  "- Durable shared work output should prefer these managed workspace paths instead of ad-hoc top-level `projects/` folders.",
366
495
  "",
496
+ "When you create or copy a skill under `$AGENT_HOME/skills/<slug>/`, check the agent's Skills snapshot before claiming it will load in future runs. If it is installed but not enabled, say exactly that future runs will not load it until enabled, and offer to enable it with `rudder agent skills enable <agent-id> <selection-ref>` when you have permission.",
497
+ "",
367
498
  "When you write issue comments or chat replies, match the language of the user's or board's most recent substantive message unless they explicitly ask for a different language.",
368
499
  "",
500
+ "When an issue comment, done comment, or blocker comment cites visual evidence from a local screenshot/image path, attach the image with the Rudder CLI `--image <path>` option instead of leaving only the filesystem path in the text.",
501
+ "",
369
502
  "## Memory and Planning",
370
503
  "",
371
504
  "You MUST use the `para-memory-files` skill for all memory operations: storing facts, writing daily notes, creating entities, running weekly synthesis, recalling past context, and managing plans. The skill defines your three-layer memory system (knowledge graph, daily notes, tacit knowledge), the PARA folder structure, atomic fact schemas, memory decay rules, and recall/planning conventions.",
@@ -379,9 +512,40 @@ export const RUDDER_AGENT_OPERATING_CONTRACT = [
379
512
  "- Never exfiltrate secrets or private data.",
380
513
  "- Do not perform any destructive commands unless explicitly requested by the board.",
381
514
  ].join("\n");
515
+ function toPromptPath(pathValue) {
516
+ return pathValue.split(path.sep).join("/");
517
+ }
518
+ function isInsidePath(parentPath, childPath) {
519
+ const relativePath = path.relative(parentPath, childPath);
520
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
521
+ }
522
+ function displayInstructionPath(filePath, instructionsFilePath) {
523
+ const resolvedFilePath = path.resolve(filePath);
524
+ const resolvedInstructionsPath = path.resolve(instructionsFilePath);
525
+ const instructionsDir = path.dirname(resolvedInstructionsPath);
526
+ if (path.basename(instructionsDir) === "instructions") {
527
+ const agentHome = path.dirname(instructionsDir);
528
+ if (isInsidePath(agentHome, resolvedFilePath)) {
529
+ const relativePath = path.relative(agentHome, resolvedFilePath);
530
+ return relativePath ? `$AGENT_HOME/${toPromptPath(relativePath)}` : "$AGENT_HOME";
531
+ }
532
+ }
533
+ return filePath;
534
+ }
535
+ function displayInstructionDir(filePath, instructionsFilePath) {
536
+ const displayPath = displayInstructionPath(filePath, instructionsFilePath);
537
+ const lastSlash = displayPath.lastIndexOf("/");
538
+ return lastSlash >= 0 ? `${displayPath.slice(0, lastSlash)}/` : "";
539
+ }
382
540
  export async function loadAgentInstructionsPrefix(input) {
383
541
  const instructionsFilePath = input.instructionsFilePath.trim();
384
542
  const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
543
+ const displayInstructionsFilePath = instructionsFilePath
544
+ ? displayInstructionPath(instructionsFilePath, instructionsFilePath)
545
+ : "";
546
+ const displayInstructionsDir = instructionsFilePath
547
+ ? displayInstructionDir(instructionsFilePath, instructionsFilePath)
548
+ : "";
385
549
  const warningStream = input.warningStream ?? "stdout";
386
550
  const operatingContractSection = `${RUDDER_AGENT_OPERATING_CONTRACT}\n\n` +
387
551
  "The above Rudder agent operating contract was injected by Rudder at runtime.";
@@ -413,29 +577,31 @@ export async function loadAgentInstructionsPrefix(input) {
413
577
  loadedPaths.add(path.resolve(instructionsFilePath));
414
578
  entrySection =
415
579
  `${instructionsContents}\n\n` +
416
- `The above agent instructions were loaded from ${instructionsFilePath}. ` +
417
- `Resolve any relative file references from ${instructionsDir}.`;
418
- await input.onLog("stdout", `[rudder] Loaded agent instructions file: ${instructionsFilePath}\n`);
580
+ `The above agent instructions were loaded from ${displayInstructionsFilePath}. ` +
581
+ `Resolve any relative file references from ${displayInstructionsDir}.`;
582
+ await input.onLog("stdout", `[rudder] Loaded agent instructions file: ${displayInstructionsFilePath}\n`);
419
583
  }
420
584
  catch (err) {
421
585
  const reason = err instanceof Error ? err.message : String(err);
422
586
  await input.onLog(warningStream, `[rudder] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`);
423
- commandNotes.push(`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`);
587
+ commandNotes.push(`Configured instructionsFilePath ${displayInstructionsFilePath}, but file could not be read; continuing without injected instructions.`);
424
588
  }
425
589
  async function loadSiblingInstructionFile(siblingInput) {
426
590
  const filePath = path.join(path.dirname(instructionsFilePath), siblingInput.fileName);
427
591
  const resolvedPath = path.resolve(filePath);
592
+ const displayFilePath = displayInstructionPath(filePath, instructionsFilePath);
593
+ const displayFileDir = displayInstructionDir(filePath, instructionsFilePath);
428
594
  if (loadedPaths.has(resolvedPath))
429
595
  return { path: filePath, section: "" };
430
596
  try {
431
597
  const contents = await fs.readFile(filePath, "utf8");
432
598
  loadedPaths.add(resolvedPath);
433
- await input.onLog("stdout", `[rudder] Loaded ${siblingInput.logLabel}: ${filePath}\n`);
599
+ await input.onLog("stdout", `[rudder] Loaded ${siblingInput.logLabel}: ${displayFilePath}\n`);
434
600
  return {
435
601
  path: filePath,
436
602
  section: `${contents}\n\n` +
437
- `The above ${siblingInput.label} were loaded from ${filePath}. ` +
438
- `Resolve any relative file references from ${instructionsDir}.`,
603
+ `The above ${siblingInput.label} were loaded from ${displayFilePath}. ` +
604
+ `Resolve any relative file references from ${displayFileDir}.`,
439
605
  };
440
606
  }
441
607
  catch (err) {
@@ -451,26 +617,29 @@ export async function loadAgentInstructionsPrefix(input) {
451
617
  label: "agent role and persona instructions",
452
618
  logLabel: "agent soul instructions file",
453
619
  });
454
- if (soul.section)
455
- commandNotes.push(`Loaded agent soul instructions from ${soul.path}`);
620
+ if (soul.section && soul.path) {
621
+ commandNotes.push(`Loaded agent soul instructions from ${displayInstructionPath(soul.path, instructionsFilePath)}`);
622
+ }
456
623
  const tools = await loadSiblingInstructionFile({
457
624
  fileName: "TOOLS.md",
458
625
  label: "agent tool notes",
459
626
  logLabel: "agent tool notes file",
460
627
  });
461
- if (tools.section)
462
- commandNotes.push(`Loaded agent tool notes from ${tools.path}`);
628
+ if (tools.section && tools.path) {
629
+ commandNotes.push(`Loaded agent tool notes from ${displayInstructionPath(tools.path, instructionsFilePath)}`);
630
+ }
463
631
  const memory = await loadSiblingInstructionFile({
464
632
  fileName: "MEMORY.md",
465
633
  label: "agent memory instructions",
466
634
  logLabel: "agent memory instructions file",
467
635
  });
468
- if (memory.section)
469
- commandNotes.push(`Loaded agent memory instructions from ${memory.path}`);
636
+ if (memory.section && memory.path) {
637
+ commandNotes.push(`Loaded agent memory instructions from ${displayInstructionPath(memory.path, instructionsFilePath)}`);
638
+ }
470
639
  const memoryFilePath = memory.section ? memory.path : null;
471
640
  const memorySection = memory.section;
472
641
  if (entrySection)
473
- commandNotes.splice(1, 0, `Loaded agent instructions from ${instructionsFilePath}`);
642
+ commandNotes.splice(1, 0, `Loaded agent instructions from ${displayInstructionsFilePath}`);
474
643
  const prefix = joinPromptSections([operatingContractSection, entrySection, soul.section, tools.section, memorySection]);
475
644
  return {
476
645
  prefix,
@@ -535,6 +704,15 @@ async function pathExists(candidate) {
535
704
  return false;
536
705
  }
537
706
  }
707
+ async function fileExists(candidate) {
708
+ try {
709
+ await fs.access(candidate, fsConstants.F_OK);
710
+ return true;
711
+ }
712
+ catch {
713
+ return false;
714
+ }
715
+ }
538
716
  async function resolveCommandPath(command, cwd, env) {
539
717
  const hasPathSeparator = command.includes("/") || command.includes("\\");
540
718
  if (hasPathSeparator) {
@@ -605,7 +783,7 @@ async function findAncestorWithFile(startDir, relativePath, maxDepth = 12) {
605
783
  let current = path.resolve(startDir);
606
784
  for (let depth = 0; depth <= maxDepth; depth += 1) {
607
785
  const candidate = path.join(current, relativePath);
608
- if (await pathExists(candidate))
786
+ if (await fileExists(candidate))
609
787
  return candidate;
610
788
  const parent = path.dirname(current);
611
789
  if (parent === current)
@@ -631,14 +809,14 @@ async function resolveRudderCliShimTarget(moduleDir) {
631
809
  const rootDir = path.dirname(path.dirname(path.dirname(repoRoot)));
632
810
  const tsxEntry = path.join(rootDir, "cli", "node_modules", "tsx", "dist", "cli.mjs");
633
811
  const cliSource = path.join(rootDir, "cli", "src", "index.ts");
634
- if (await pathExists(tsxEntry)) {
812
+ if (await fileExists(tsxEntry)) {
635
813
  return {
636
814
  command: process.execPath,
637
815
  args: [tsxEntry, cliSource],
638
816
  };
639
817
  }
640
818
  const builtCliEntry = path.join(rootDir, "cli", "dist", "index.js");
641
- if (await pathExists(builtCliEntry)) {
819
+ if (await fileExists(builtCliEntry)) {
642
820
  return {
643
821
  command: process.execPath,
644
822
  args: [builtCliEntry],
@@ -667,10 +845,6 @@ async function materializeRudderCliShim(target) {
667
845
  }
668
846
  export async function ensureRudderCliInPath(moduleDir, env) {
669
847
  const normalized = ensurePathInEnv(env);
670
- const cwd = process.cwd();
671
- if (await resolveCommandPath("rudder", cwd, normalized)) {
672
- return normalized;
673
- }
674
848
  const target = await resolveRudderCliShimTarget(moduleDir);
675
849
  if (!target) {
676
850
  return normalized;
@@ -945,6 +1119,230 @@ export function writeRudderSkillSyncPreference(config, desiredSkills) {
945
1119
  next.rudderSkillSync = current;
946
1120
  return next;
947
1121
  }
1122
+ function nonEmptyEnvPath(value) {
1123
+ return typeof value === "string" && value.trim().length > 0 ? path.resolve(value.trim()) : null;
1124
+ }
1125
+ export function resolveLocalOperatorHome(sourceEnv = process.env) {
1126
+ return (nonEmptyEnvPath(sourceEnv.RUDDER_OPERATOR_HOME)
1127
+ ?? nonEmptyEnvPath(process.env.RUDDER_OPERATOR_HOME)
1128
+ ?? nonEmptyEnvPath(process.env.HOME)
1129
+ ?? nonEmptyEnvPath(sourceEnv.HOME)
1130
+ ?? path.resolve(os.homedir()));
1131
+ }
1132
+ export function applyLocalCliHomeEnv(targetEnv, sourceEnv = process.env) {
1133
+ const home = nonEmptyEnvPath(sourceEnv.HOME) ?? path.resolve(os.homedir());
1134
+ targetEnv.HOME = home;
1135
+ const userProfile = nonEmptyEnvPath(sourceEnv.USERPROFILE);
1136
+ if (userProfile) {
1137
+ targetEnv.USERPROFILE = userProfile;
1138
+ }
1139
+ else if (process.platform === "win32") {
1140
+ targetEnv.USERPROFILE = home;
1141
+ }
1142
+ }
1143
+ async function localCliPathExists(candidate) {
1144
+ return fs.access(candidate).then(() => true).catch(() => false);
1145
+ }
1146
+ async function directoryIsEmpty(target) {
1147
+ const entries = await fs.readdir(target).catch(() => null);
1148
+ return Array.isArray(entries) && entries.length === 0;
1149
+ }
1150
+ async function ensureSymlinkToSource(target, source) {
1151
+ const existing = await fs.lstat(target).catch(() => null);
1152
+ if (!existing) {
1153
+ await fs.mkdir(path.dirname(target), { recursive: true });
1154
+ await fs.symlink(source, target);
1155
+ return "created";
1156
+ }
1157
+ if (!existing.isSymbolicLink()) {
1158
+ if (existing.isDirectory() && await directoryIsEmpty(target)) {
1159
+ await fs.rmdir(target);
1160
+ await fs.symlink(source, target);
1161
+ return "repaired";
1162
+ }
1163
+ return "skipped";
1164
+ }
1165
+ const linkedPath = await fs.readlink(target).catch(() => null);
1166
+ if (!linkedPath)
1167
+ return "skipped";
1168
+ const resolvedLinkedPath = path.isAbsolute(linkedPath)
1169
+ ? linkedPath
1170
+ : path.resolve(path.dirname(target), linkedPath);
1171
+ if (resolvedLinkedPath === source)
1172
+ return "skipped";
1173
+ await fs.unlink(target);
1174
+ await fs.symlink(source, target);
1175
+ return "repaired";
1176
+ }
1177
+ export async function syncLocalCliCredentialHomeEntries(input) {
1178
+ const sourceHome = nonEmptyEnvPath(input.sourceHome ?? undefined) ?? path.resolve(os.homedir());
1179
+ const targetHome = path.resolve(input.targetHome);
1180
+ const linked = [];
1181
+ const skipped = [];
1182
+ if (sourceHome === targetHome)
1183
+ return { linked, skipped };
1184
+ const entries = input.entries ?? DEFAULT_LOCAL_CLI_CREDENTIAL_HOME_ENTRIES;
1185
+ for (const relativeEntry of entries) {
1186
+ const source = path.join(sourceHome, relativeEntry);
1187
+ if (!(await localCliPathExists(source)))
1188
+ continue;
1189
+ const target = path.join(targetHome, relativeEntry);
1190
+ try {
1191
+ const result = await ensureSymlinkToSource(target, source);
1192
+ if (result === "skipped")
1193
+ skipped.push(relativeEntry);
1194
+ else
1195
+ linked.push(relativeEntry);
1196
+ }
1197
+ catch {
1198
+ skipped.push(relativeEntry);
1199
+ }
1200
+ }
1201
+ if (input.onLog && linked.length > 0) {
1202
+ await input.onLog("stdout", `[rudder] Shared ${linked.length} local CLI credential entr${linked.length === 1 ? "y" : "ies"} into managed HOME ${targetHome}: ${linked.join(", ")}\n`);
1203
+ }
1204
+ return { linked, skipped };
1205
+ }
1206
+ async function writeOperatorHomeShim(input) {
1207
+ await fs.mkdir(input.shimDir, { recursive: true });
1208
+ if (process.platform === "win32") {
1209
+ const shimPath = path.join(input.shimDir, `${input.command}.cmd`);
1210
+ const lines = [
1211
+ "@echo off",
1212
+ `set "HOME=${input.operatorHome}"`,
1213
+ `set "USERPROFILE=${input.operatorHome}"`,
1214
+ `${quoteForCmd(input.targetCommand)} %*`,
1215
+ "",
1216
+ ];
1217
+ await fs.writeFile(shimPath, lines.join("\r\n"), "utf8");
1218
+ return shimPath;
1219
+ }
1220
+ const shimPath = path.join(input.shimDir, input.command);
1221
+ await fs.writeFile(shimPath, [
1222
+ "#!/bin/sh",
1223
+ `export HOME=${shellQuote(input.operatorHome)}`,
1224
+ `export USERPROFILE=${shellQuote(input.operatorHome)}`,
1225
+ `exec ${shellQuote(input.targetCommand)} "$@"`,
1226
+ "",
1227
+ ].join("\n"), "utf8");
1228
+ await fs.chmod(shimPath, 0o755);
1229
+ return shimPath;
1230
+ }
1231
+ function normalizeShimCommand(input) {
1232
+ return typeof input === "string" ? { command: input } : input;
1233
+ }
1234
+ async function runCredentialShimAuthCheck(input) {
1235
+ const env = {
1236
+ ...input.env,
1237
+ HOME: input.home,
1238
+ USERPROFILE: input.home,
1239
+ };
1240
+ return await new Promise((resolve) => {
1241
+ const child = spawn(input.targetCommand, [...input.args], {
1242
+ cwd: input.cwd,
1243
+ env,
1244
+ stdio: ["ignore", "ignore", "ignore"],
1245
+ });
1246
+ const timeout = setTimeout(() => {
1247
+ child.kill("SIGTERM");
1248
+ resolve(false);
1249
+ }, 1000);
1250
+ child.on("error", () => {
1251
+ clearTimeout(timeout);
1252
+ resolve(false);
1253
+ });
1254
+ child.on("close", (code) => {
1255
+ clearTimeout(timeout);
1256
+ resolve(code === 0);
1257
+ });
1258
+ });
1259
+ }
1260
+ async function credentialBridgeSatisfied(input) {
1261
+ for (const entry of input.entries) {
1262
+ const source = path.join(input.operatorHome, entry);
1263
+ const target = path.join(input.targetHome, entry);
1264
+ if (!(await localCliPathExists(source)) || !(await localCliPathExists(target)))
1265
+ continue;
1266
+ const [sourceRealpath, targetRealpath] = await Promise.all([
1267
+ fs.realpath(source).catch(() => null),
1268
+ fs.realpath(target).catch(() => null),
1269
+ ]);
1270
+ if (sourceRealpath && targetRealpath && sourceRealpath === targetRealpath)
1271
+ return true;
1272
+ }
1273
+ return false;
1274
+ }
1275
+ async function shouldPrepareOperatorHomeShim(input) {
1276
+ const authCheckArgs = input.command.authCheckArgs;
1277
+ if (!authCheckArgs || authCheckArgs.length === 0)
1278
+ return true;
1279
+ if (input.command.credentialEntries && input.command.credentialEntries.length > 0) {
1280
+ const hasOperatorCredentialEntry = await Promise.all(input.command.credentialEntries.map((entry) => localCliPathExists(path.join(input.operatorHome, entry))));
1281
+ if (!hasOperatorCredentialEntry.some(Boolean))
1282
+ return false;
1283
+ if (await credentialBridgeSatisfied({
1284
+ operatorHome: input.operatorHome,
1285
+ targetHome: input.targetHome,
1286
+ entries: input.command.credentialEntries,
1287
+ })) {
1288
+ return false;
1289
+ }
1290
+ }
1291
+ const managedHomeWorks = await runCredentialShimAuthCheck({
1292
+ targetCommand: input.targetCommand,
1293
+ args: authCheckArgs,
1294
+ cwd: input.cwd,
1295
+ env: input.env,
1296
+ home: input.targetHome,
1297
+ });
1298
+ if (managedHomeWorks)
1299
+ return false;
1300
+ return await runCredentialShimAuthCheck({
1301
+ targetCommand: input.targetCommand,
1302
+ args: authCheckArgs,
1303
+ cwd: input.cwd,
1304
+ env: input.env,
1305
+ home: input.operatorHome,
1306
+ });
1307
+ }
1308
+ export async function ensureLocalCliCredentialShimsInPath(input) {
1309
+ const operatorHome = nonEmptyEnvPath(input.operatorHome ?? undefined);
1310
+ const targetHome = nonEmptyEnvPath(input.targetHome);
1311
+ if (!operatorHome || !targetHome || operatorHome === targetHome) {
1312
+ return ensurePathInEnv(input.env);
1313
+ }
1314
+ const normalized = ensurePathInEnv(input.env);
1315
+ const cwd = input.cwd ?? process.cwd();
1316
+ const commands = input.commands ?? DEFAULT_LOCAL_CLI_OPERATOR_HOME_SHIM_COMMANDS;
1317
+ const shimDir = path.join(targetHome, ".rudder", "local-cli-shims");
1318
+ const prepared = [];
1319
+ for (const rawCommand of commands) {
1320
+ const command = normalizeShimCommand(rawCommand);
1321
+ const targetCommand = await resolveCommandPath(command.command, cwd, normalized);
1322
+ if (!targetCommand)
1323
+ continue;
1324
+ if (path.dirname(targetCommand) === shimDir)
1325
+ continue;
1326
+ if (!(await shouldPrepareOperatorHomeShim({
1327
+ command,
1328
+ targetCommand,
1329
+ cwd,
1330
+ env: normalized,
1331
+ targetHome,
1332
+ operatorHome,
1333
+ }))) {
1334
+ continue;
1335
+ }
1336
+ await writeOperatorHomeShim({ shimDir, command: command.command, targetCommand, operatorHome });
1337
+ prepared.push(command.command);
1338
+ }
1339
+ if (prepared.length === 0)
1340
+ return normalized;
1341
+ if (input.onLog) {
1342
+ await input.onLog("stdout", `[rudder] Prepared local CLI credential shim${prepared.length === 1 ? "" : "s"} for: ${prepared.join(", ")}\n`);
1343
+ }
1344
+ return prependPathEntry(normalized, shimDir);
1345
+ }
948
1346
  export async function ensureRudderSkillSymlink(source, target, linkSkill = (linkSource, linkTarget) => fs.symlink(linkSource, linkTarget)) {
949
1347
  const existing = await fs.lstat(target).catch(() => null);
950
1348
  if (!existing) {
@@ -1035,6 +1433,17 @@ export async function runChildProcess(runId, command, args, opts) {
1035
1433
  for (const key of CLAUDE_CODE_NESTING_VARS) {
1036
1434
  delete rawMerged[key];
1037
1435
  }
1436
+ const GIT_IDENTITY_ENV_VARS = [
1437
+ "GIT_AUTHOR_NAME",
1438
+ "GIT_AUTHOR_EMAIL",
1439
+ "GIT_COMMITTER_NAME",
1440
+ "GIT_COMMITTER_EMAIL",
1441
+ ];
1442
+ for (const key of GIT_IDENTITY_ENV_VARS) {
1443
+ if (rawMerged[key] === "" && !Object.prototype.hasOwnProperty.call(opts.env, key)) {
1444
+ delete rawMerged[key];
1445
+ }
1446
+ }
1038
1447
  // When Rudder isolates HOME for child agents, don't let zsh keep using the
1039
1448
  // host user's startup dir via an inherited ZDOTDIR. That mismatch makes
1040
1449
  // child `zsh -lc` invocations source the host `.zshenv` with the agent HOME.