siclaw 0.1.2 → 0.1.4

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 (215) hide show
  1. package/dist/agentbox/gateway-client.d.ts +4 -0
  2. package/dist/agentbox/gateway-client.js +9 -1
  3. package/dist/agentbox/gateway-client.js.map +1 -1
  4. package/dist/agentbox/http-server.js +25 -1
  5. package/dist/agentbox/http-server.js.map +1 -1
  6. package/dist/agentbox/session.d.ts +2 -0
  7. package/dist/agentbox/session.js +11 -7
  8. package/dist/agentbox/session.js.map +1 -1
  9. package/dist/agentbox-main.js +10 -0
  10. package/dist/agentbox-main.js.map +1 -1
  11. package/dist/cli-main.js +19 -3
  12. package/dist/cli-main.js.map +1 -1
  13. package/dist/core/agent-factory.d.ts +2 -0
  14. package/dist/core/agent-factory.js +87 -21
  15. package/dist/core/agent-factory.js.map +1 -1
  16. package/dist/core/compaction.d.ts +80 -0
  17. package/dist/core/compaction.js +442 -0
  18. package/dist/core/compaction.js.map +1 -0
  19. package/dist/core/config.d.ts +7 -0
  20. package/dist/core/config.js +27 -1
  21. package/dist/core/config.js.map +1 -1
  22. package/dist/core/extensions/compaction-safeguard.d.ts +2 -0
  23. package/dist/core/extensions/compaction-safeguard.js +681 -0
  24. package/dist/core/extensions/compaction-safeguard.js.map +1 -0
  25. package/dist/core/extensions/deep-investigation.js +47 -73
  26. package/dist/core/extensions/deep-investigation.js.map +1 -1
  27. package/dist/core/extensions/memory-flush.d.ts +2 -10
  28. package/dist/core/extensions/memory-flush.js +4 -86
  29. package/dist/core/extensions/memory-flush.js.map +1 -1
  30. package/dist/core/llm-proxy.js +25 -6
  31. package/dist/core/llm-proxy.js.map +1 -1
  32. package/dist/core/message-utils.d.ts +18 -0
  33. package/dist/core/message-utils.js +28 -0
  34. package/dist/core/message-utils.js.map +1 -0
  35. package/dist/core/prompt.js +4 -5
  36. package/dist/core/prompt.js.map +1 -1
  37. package/dist/core/session-tool-result-guard.d.ts +2 -0
  38. package/dist/core/session-tool-result-guard.js +159 -0
  39. package/dist/core/session-tool-result-guard.js.map +1 -0
  40. package/dist/core/stream-wrappers.d.ts +41 -0
  41. package/dist/core/stream-wrappers.js +369 -0
  42. package/dist/core/stream-wrappers.js.map +1 -0
  43. package/dist/core/thinking-blocks.d.ts +20 -0
  44. package/dist/core/thinking-blocks.js +45 -0
  45. package/dist/core/thinking-blocks.js.map +1 -0
  46. package/dist/core/tool-call-id.d.ts +22 -0
  47. package/dist/core/tool-call-id.js +226 -0
  48. package/dist/core/tool-call-id.js.map +1 -0
  49. package/dist/core/tool-call-repair.d.ts +18 -0
  50. package/dist/core/tool-call-repair.js +73 -0
  51. package/dist/core/tool-call-repair.js.map +1 -0
  52. package/dist/core/tool-result-context-guard.d.ts +36 -0
  53. package/dist/core/tool-result-context-guard.js +272 -0
  54. package/dist/core/tool-result-context-guard.js.map +1 -0
  55. package/dist/cron/cron-limits.d.ts +16 -0
  56. package/dist/cron/cron-limits.js +17 -0
  57. package/dist/cron/cron-limits.js.map +1 -0
  58. package/dist/cron/cron-matcher.d.ts +14 -0
  59. package/dist/cron/cron-matcher.js +29 -0
  60. package/dist/cron/cron-matcher.js.map +1 -1
  61. package/dist/gateway/agentbox/client.d.ts +0 -2
  62. package/dist/gateway/agentbox/client.js.map +1 -1
  63. package/dist/gateway/agentbox/k8s-spawner.d.ts +10 -10
  64. package/dist/gateway/agentbox/k8s-spawner.js +27 -55
  65. package/dist/gateway/agentbox/k8s-spawner.js.map +1 -1
  66. package/dist/gateway/agentbox/local-spawner.d.ts +5 -0
  67. package/dist/gateway/agentbox/local-spawner.js +10 -0
  68. package/dist/gateway/agentbox/local-spawner.js.map +1 -1
  69. package/dist/gateway/cron/cron-service.js +7 -0
  70. package/dist/gateway/cron/cron-service.js.map +1 -1
  71. package/dist/gateway/db/index.js +9 -1
  72. package/dist/gateway/db/index.js.map +1 -1
  73. package/dist/gateway/db/init-schema.js +65 -16
  74. package/dist/gateway/db/init-schema.js.map +1 -1
  75. package/dist/gateway/db/migrate-sqlite.js +73 -20
  76. package/dist/gateway/db/migrate-sqlite.js.map +1 -1
  77. package/dist/gateway/db/repositories/cluster-repo.d.ts +59 -0
  78. package/dist/gateway/db/repositories/cluster-repo.js +107 -0
  79. package/dist/gateway/db/repositories/cluster-repo.js.map +1 -0
  80. package/dist/gateway/db/repositories/config-repo.d.ts +4 -5
  81. package/dist/gateway/db/repositories/config-repo.js +17 -0
  82. package/dist/gateway/db/repositories/config-repo.js.map +1 -1
  83. package/dist/gateway/db/repositories/feedback-repo.d.ts +71 -0
  84. package/dist/gateway/db/repositories/feedback-repo.js +52 -0
  85. package/dist/gateway/db/repositories/feedback-repo.js.map +1 -0
  86. package/dist/gateway/db/repositories/knowledge-doc-repo.d.ts +37 -0
  87. package/dist/gateway/db/repositories/knowledge-doc-repo.js +48 -0
  88. package/dist/gateway/db/repositories/knowledge-doc-repo.js.map +1 -0
  89. package/dist/gateway/db/repositories/user-cluster-config-repo.d.ts +45 -0
  90. package/dist/gateway/db/repositories/user-cluster-config-repo.js +90 -0
  91. package/dist/gateway/db/repositories/user-cluster-config-repo.js.map +1 -0
  92. package/dist/gateway/db/repositories/workspace-repo.d.ts +2 -2
  93. package/dist/gateway/db/repositories/workspace-repo.js +12 -12
  94. package/dist/gateway/db/repositories/workspace-repo.js.map +1 -1
  95. package/dist/gateway/db/schema-mysql.d.ts +437 -44
  96. package/dist/gateway/db/schema-mysql.js +36 -9
  97. package/dist/gateway/db/schema-mysql.js.map +1 -1
  98. package/dist/gateway/db/schema-sqlite.d.ts +459 -46
  99. package/dist/gateway/db/schema-sqlite.js +36 -9
  100. package/dist/gateway/db/schema-sqlite.js.map +1 -1
  101. package/dist/gateway/db/schema.d.ts +435 -44
  102. package/dist/gateway/db/schema.js +1 -1
  103. package/dist/gateway/db/schema.js.map +1 -1
  104. package/dist/gateway/plugins/channel-bridge.js +1 -1
  105. package/dist/gateway/plugins/channel-bridge.js.map +1 -1
  106. package/dist/gateway/rpc-methods.d.ts +2 -1
  107. package/dist/gateway/rpc-methods.js +507 -172
  108. package/dist/gateway/rpc-methods.js.map +1 -1
  109. package/dist/gateway/server.js +191 -51
  110. package/dist/gateway/server.js.map +1 -1
  111. package/dist/gateway/web/dist/assets/index-DTD0P9j8.css +1 -0
  112. package/dist/gateway/web/dist/assets/index-DhqsS2E0.js +756 -0
  113. package/dist/gateway/web/dist/assets/index-DhqsS2E0.js.map +1 -0
  114. package/dist/gateway/web/dist/index.html +2 -2
  115. package/dist/gateway-main.js +1 -3
  116. package/dist/gateway-main.js.map +1 -1
  117. package/dist/memory/indexer.d.ts +13 -0
  118. package/dist/memory/indexer.js +91 -1
  119. package/dist/memory/indexer.js.map +1 -1
  120. package/dist/memory/knowledge-extractor.d.ts +47 -0
  121. package/dist/memory/knowledge-extractor.js +165 -0
  122. package/dist/memory/knowledge-extractor.js.map +1 -0
  123. package/dist/memory/overview-generator.d.ts +16 -0
  124. package/dist/memory/overview-generator.js +233 -0
  125. package/dist/memory/overview-generator.js.map +1 -0
  126. package/dist/memory/session-summarizer.d.ts +28 -0
  127. package/dist/memory/session-summarizer.js +20 -2
  128. package/dist/memory/session-summarizer.js.map +1 -1
  129. package/dist/memory/temporal-decay.js +2 -2
  130. package/dist/memory/temporal-decay.js.map +1 -1
  131. package/dist/memory/topic-consolidator.d.ts +52 -0
  132. package/dist/memory/topic-consolidator.js +197 -0
  133. package/dist/memory/topic-consolidator.js.map +1 -0
  134. package/dist/tools/cluster-info.d.ts +9 -0
  135. package/dist/tools/cluster-info.js +74 -0
  136. package/dist/tools/cluster-info.js.map +1 -0
  137. package/dist/tools/command-sets.js +15 -5
  138. package/dist/tools/command-sets.js.map +1 -1
  139. package/dist/tools/create-skill.js +1 -1
  140. package/dist/tools/create-skill.js.map +1 -1
  141. package/dist/tools/debug-pod.d.ts +217 -0
  142. package/dist/tools/debug-pod.js +603 -0
  143. package/dist/tools/debug-pod.js.map +1 -0
  144. package/dist/tools/deep-search/engine.d.ts +0 -5
  145. package/dist/tools/deep-search/engine.js +68 -28
  146. package/dist/tools/deep-search/engine.js.map +1 -1
  147. package/dist/tools/deep-search/format.d.ts +1 -1
  148. package/dist/tools/deep-search/format.js +1 -2
  149. package/dist/tools/deep-search/format.js.map +1 -1
  150. package/dist/tools/deep-search/prompts.d.ts +4 -1
  151. package/dist/tools/deep-search/prompts.js +47 -29
  152. package/dist/tools/deep-search/prompts.js.map +1 -1
  153. package/dist/tools/deep-search/quality-gate.d.ts +25 -0
  154. package/dist/tools/deep-search/quality-gate.js +81 -0
  155. package/dist/tools/deep-search/quality-gate.js.map +1 -0
  156. package/dist/tools/deep-search/schemas.d.ts +25 -0
  157. package/dist/tools/deep-search/schemas.js +26 -1
  158. package/dist/tools/deep-search/schemas.js.map +1 -1
  159. package/dist/tools/deep-search/sre-knowledge.d.ts +6 -10
  160. package/dist/tools/deep-search/sre-knowledge.js +21 -52
  161. package/dist/tools/deep-search/sre-knowledge.js.map +1 -1
  162. package/dist/tools/deep-search/sub-agent.js +24 -8
  163. package/dist/tools/deep-search/sub-agent.js.map +1 -1
  164. package/dist/tools/deep-search/tool.js +3 -6
  165. package/dist/tools/deep-search/tool.js.map +1 -1
  166. package/dist/tools/deep-search/types.d.ts +13 -0
  167. package/dist/tools/deep-search/types.js +4 -4
  168. package/dist/tools/deep-search/types.js.map +1 -1
  169. package/dist/tools/dp-tools.d.ts +9 -6
  170. package/dist/tools/dp-tools.js +26 -55
  171. package/dist/tools/dp-tools.js.map +1 -1
  172. package/dist/tools/exec-utils.d.ts +8 -21
  173. package/dist/tools/exec-utils.js +11 -95
  174. package/dist/tools/exec-utils.js.map +1 -1
  175. package/dist/tools/fork-skill.js +1 -1
  176. package/dist/tools/fork-skill.js.map +1 -1
  177. package/dist/tools/k8s-checks.d.ts +11 -5
  178. package/dist/tools/k8s-checks.js +28 -9
  179. package/dist/tools/k8s-checks.js.map +1 -1
  180. package/dist/tools/knowledge-search.d.ts +3 -0
  181. package/dist/tools/knowledge-search.js +115 -0
  182. package/dist/tools/knowledge-search.js.map +1 -0
  183. package/dist/tools/kubeconfig-resolver.d.ts +22 -0
  184. package/dist/tools/kubeconfig-resolver.js +98 -18
  185. package/dist/tools/kubeconfig-resolver.js.map +1 -1
  186. package/dist/tools/manage-schedule.js +23 -1
  187. package/dist/tools/manage-schedule.js.map +1 -1
  188. package/dist/tools/netns-script.d.ts +1 -1
  189. package/dist/tools/netns-script.js +19 -7
  190. package/dist/tools/netns-script.js.map +1 -1
  191. package/dist/tools/node-exec.d.ts +1 -1
  192. package/dist/tools/node-exec.js +19 -7
  193. package/dist/tools/node-exec.js.map +1 -1
  194. package/dist/tools/node-script.d.ts +1 -1
  195. package/dist/tools/node-script.js +19 -7
  196. package/dist/tools/node-script.js.map +1 -1
  197. package/dist/tools/pod-exec.js +12 -1
  198. package/dist/tools/pod-exec.js.map +1 -1
  199. package/dist/tools/pod-nsenter-exec.d.ts +1 -1
  200. package/dist/tools/pod-nsenter-exec.js +19 -7
  201. package/dist/tools/pod-nsenter-exec.js.map +1 -1
  202. package/dist/tools/pod-script.js +12 -1
  203. package/dist/tools/pod-script.js.map +1 -1
  204. package/dist/tools/restricted-bash.js +10 -3
  205. package/dist/tools/restricted-bash.js.map +1 -1
  206. package/dist/tools/run-skill.js +14 -2
  207. package/dist/tools/run-skill.js.map +1 -1
  208. package/dist/tools/save-feedback.d.ts +7 -0
  209. package/dist/tools/save-feedback.js +125 -0
  210. package/dist/tools/save-feedback.js.map +1 -0
  211. package/dist/tools/update-skill.js +1 -1
  212. package/dist/tools/update-skill.js.map +1 -1
  213. package/package.json +1 -1
  214. package/skills/core/deep-investigation/SKILL.md +11 -14
  215. package/skills/core/session-feedback/SKILL.md +146 -0
@@ -21,8 +21,8 @@ import { ModelConfigRepository } from "./db/repositories/model-config-repo.js";
21
21
  import { CredentialRepository } from "./db/repositories/credential-repo.js";
22
22
  import { WorkspaceRepository } from "./db/repositories/workspace-repo.js";
23
23
  import { SystemConfigRepository } from "./db/repositories/system-config-repo.js";
24
- import { EnvironmentRepository } from "./db/repositories/env-repo.js";
25
- import { UserEnvConfigRepository } from "./db/repositories/user-env-config-repo.js";
24
+ import { ClusterRepository } from "./db/repositories/cluster-repo.js";
25
+ import { UserClusterConfigRepository } from "./db/repositories/user-cluster-config-repo.js";
26
26
  import { getLabelsForSkill, batchGetLabels, listAllLabels } from "./skill-labels.js";
27
27
  import { McpServerRepository } from "./db/repositories/mcp-server-repo.js";
28
28
  import { SkillFileWriter } from "./skills/file-writer.js";
@@ -31,11 +31,16 @@ import { ScriptEvaluator } from "./skills/script-evaluator.js";
31
31
  import { SkillVersionRepository } from "./db/repositories/skill-version-repo.js";
32
32
  import { createTwoFilesPatch } from "diff";
33
33
  import yaml from "js-yaml";
34
+ import { CRON_LIMITS } from "../cron/cron-limits.js";
35
+ import { parseCronExpression, getAverageIntervalMs } from "../cron/cron-matcher.js";
34
36
  import { buildSkillBundle } from "./skills/skill-bundle.js";
35
37
  import { buildRedactionConfig, redactText } from "./output-redactor.js";
36
38
  import { RESOURCE_DESCRIPTORS } from "../shared/resource-sync.js";
37
39
  import { sql, gte, sum, count } from "drizzle-orm";
38
40
  import { sessionStats } from "./db/schema.js";
41
+ import { KnowledgeDocRepository } from "./db/repositories/knowledge-doc-repo.js";
42
+ import { resolveUnderDir } from "../shared/path-utils.js";
43
+ import { loadConfig } from "../core/config.js";
39
44
  function requireAuth(context) {
40
45
  const userId = context.auth?.userId;
41
46
  if (!userId)
@@ -69,7 +74,58 @@ function apiServerHostMatch(kubeconfigServer, envApiServer) {
69
74
  return false;
70
75
  }
71
76
  }
72
- export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, activePromptUsers, agentBoxTlsOptions, resourceNotifier, metricsAggregator, cronService) {
77
+ // ── Feedback enrichment helpers ──────────────────────
78
+ /**
79
+ * Read the session-feedback SKILL.md content, stripping YAML frontmatter.
80
+ * Uses import.meta.url to resolve the package root (works in both dev and K8s).
81
+ */
82
+ const _feedbackModDir = path.dirname(fileURLToPath(import.meta.url));
83
+ const _feedbackPkgRoot = path.resolve(_feedbackModDir, "..", "..");
84
+ let _feedbackSkillCache = null;
85
+ async function readFeedbackSkillContent() {
86
+ if (_feedbackSkillCache)
87
+ return _feedbackSkillCache;
88
+ const skillPath = path.join(_feedbackPkgRoot, "skills", "core", "session-feedback", "SKILL.md");
89
+ const raw = await fs.promises.readFile(skillPath, "utf-8");
90
+ // Strip YAML frontmatter (between --- delimiters)
91
+ const stripped = raw.replace(/^---[\s\S]*?---\s*/, "").trim();
92
+ _feedbackSkillCache = stripped;
93
+ return stripped;
94
+ }
95
+ /**
96
+ * Build a markdown timeline of the session's diagnostic activity.
97
+ */
98
+ async function buildSessionTimeline(chatRepo, sessionId) {
99
+ const msgs = await chatRepo.getMessages(sessionId, { limit: 200 });
100
+ if (msgs.length === 0)
101
+ return "_No messages in this session._";
102
+ const lines = [];
103
+ let stepNum = 0;
104
+ for (const msg of msgs) {
105
+ if (msg.role === "user") {
106
+ // Skip feedback sentinel messages from timeline
107
+ if (msg.content.startsWith("[Feedback]"))
108
+ continue;
109
+ stepNum++;
110
+ const preview = msg.content.length > 100 ? msg.content.slice(0, 100) + "..." : msg.content;
111
+ lines.push(`${stepNum}. **User**: ${preview}`);
112
+ }
113
+ else if (msg.role === "tool" && msg.toolName) {
114
+ stepNum++;
115
+ const outcome = msg.outcome ?? "unknown";
116
+ const duration = msg.durationMs != null ? ` (${msg.durationMs}ms)` : "";
117
+ lines.push(`${stepNum}. **Tool** \`${msg.toolName}\`: ${outcome}${duration}`);
118
+ }
119
+ else if (msg.role === "assistant") {
120
+ // Summarize assistant messages briefly
121
+ const preview = msg.content.length > 100 ? msg.content.slice(0, 100) + "..." : msg.content;
122
+ stepNum++;
123
+ lines.push(`${stepNum}. **Assistant**: ${preview}`);
124
+ }
125
+ }
126
+ return lines.join("\n");
127
+ }
128
+ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, activePromptUsers, agentBoxTlsOptions, resourceNotifier, metricsAggregator, cronService, knowledgeIndexer) {
73
129
  const methods = new Map();
74
130
  // Initialize repositories (null-safe — methods check before use)
75
131
  const chatRepo = db ? new ChatRepository(db) : null;
@@ -86,9 +142,10 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
86
142
  const workspaceRepo = db ? new WorkspaceRepository(db) : null;
87
143
  const sysConfigRepo = db ? new SystemConfigRepository(db) : null;
88
144
  const mcpRepo = db ? new McpServerRepository(db) : null;
145
+ const knowledgeDocRepo = db ? new KnowledgeDocRepository(db) : null;
89
146
  const skillContentRepo = db ? new SkillContentRepository(db) : null;
90
- const envRepo = db ? new EnvironmentRepository(db) : null;
91
- const userEnvConfigRepo = db ? new UserEnvConfigRepository(db) : null;
147
+ const clusterRepo = db ? new ClusterRepository(db) : null;
148
+ const userClusterConfigRepo = db ? new UserClusterConfigRepository(db) : null;
92
149
  const scriptEvaluator = new ScriptEvaluator(modelConfigRepo);
93
150
  /** Resolve workspaceId for a session from DB */
94
151
  async function resolveSessionWorkspace(sessionId) {
@@ -366,12 +423,14 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
366
423
  content: message,
367
424
  });
368
425
  await chatRepo.incrementMessageCount(sessionId);
369
- // Update session metadata (title/preview)
370
- const title = message.length > 40 ? message.slice(0, 40) + "..." : message;
371
- await chatRepo.updateSessionMeta(sessionId, {
372
- title,
373
- preview: message.slice(0, 100),
374
- });
426
+ // Update session metadata (title/preview) — skip for feedback sentinel
427
+ if (!message.startsWith("[Feedback]")) {
428
+ const title = message.length > 40 ? message.slice(0, 40) + "..." : message;
429
+ await chatRepo.updateSessionMeta(sessionId, {
430
+ title,
431
+ preview: message.slice(0, 100),
432
+ });
433
+ }
375
434
  }
376
435
  if (!sessionId)
377
436
  throw new Error("Failed to create session");
@@ -417,8 +476,30 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
417
476
  podEnv,
418
477
  });
419
478
  const client = new AgentBoxClient(handle.endpoint, 30000, agentBoxTlsOptions);
479
+ // === Feedback enrichment (system-level) ===
480
+ let promptText = message;
481
+ if (message.startsWith("[Feedback]") && !chatRepo) {
482
+ throw new Error("Feedback requires a database connection (not available in TUI mode).");
483
+ }
484
+ if (message.startsWith("[Feedback]") && chatRepo) {
485
+ try {
486
+ const skillContent = await readFeedbackSkillContent();
487
+ const timeline = await buildSessionTimeline(chatRepo, sessionId);
488
+ promptText = [
489
+ "## Session Feedback Instructions\n",
490
+ skillContent,
491
+ "\n## Current Session Diagnostic Timeline\n",
492
+ timeline,
493
+ "\n---\nThe user has requested a feedback session. Begin the interactive review now.",
494
+ ].join("\n");
495
+ }
496
+ catch (err) {
497
+ const reason = err instanceof Error ? err.message : String(err);
498
+ throw new Error(`Feedback system unavailable: ${reason}`);
499
+ }
500
+ }
420
501
  // Send prompt
421
- const result = await client.prompt({ sessionId, text: message, modelProvider, modelId, brainType, modelConfig, credentials });
502
+ const result = await client.prompt({ sessionId, text: promptText, modelProvider, modelId, brainType, modelConfig, credentials });
422
503
  console.log(`[rpc] prompt sent → sessionId=${result.sessionId}`);
423
504
  // Build redaction config from credential payload + model secrets (sanitize outbound WS stream)
424
505
  const sensitiveStrings = [];
@@ -943,7 +1024,6 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
943
1024
  }
944
1025
  // Fallback: read from settings.json mcpServers (CLI / no-DB mode)
945
1026
  try {
946
- const { loadConfig } = await import("../core/config.js");
947
1027
  const config = loadConfig();
948
1028
  const servers = [];
949
1029
  for (const [name, serverConfig] of Object.entries(config.mcpServers ?? {})) {
@@ -1041,6 +1121,155 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1041
1121
  await notifyMcpChange();
1042
1122
  return { id, enabled: newEnabled };
1043
1123
  });
1124
+ // ── Knowledge Base ────────────────────────────────
1125
+ const knowledgeDir = path.resolve(process.cwd(), loadConfig().paths.knowledgeDir);
1126
+ methods.set("kb.list", async (_params, context) => {
1127
+ requireAdmin(context);
1128
+ if (!knowledgeDocRepo)
1129
+ throw new Error("Database not available");
1130
+ const rows = await knowledgeDocRepo.list();
1131
+ return {
1132
+ docs: rows.map((r) => ({
1133
+ id: r.id,
1134
+ name: r.name,
1135
+ filePath: r.filePath,
1136
+ sizeBytes: r.sizeBytes,
1137
+ chunkCount: r.chunkCount,
1138
+ uploadedBy: r.uploadedBy,
1139
+ createdAt: r.createdAt?.toISOString(),
1140
+ updatedAt: r.updatedAt?.toISOString(),
1141
+ })),
1142
+ };
1143
+ });
1144
+ methods.set("kb.get", async (params, context) => {
1145
+ requireAdmin(context);
1146
+ if (!knowledgeDocRepo)
1147
+ throw new Error("Database not available");
1148
+ const id = params.id;
1149
+ if (!id)
1150
+ throw new Error("Missing required param: id");
1151
+ const doc = await knowledgeDocRepo.getById(id);
1152
+ if (!doc)
1153
+ throw new Error("Document not found");
1154
+ // Read file content (path traversal protected)
1155
+ const fullPath = resolveUnderDir(knowledgeDir, doc.filePath);
1156
+ let content = "";
1157
+ if (fs.existsSync(fullPath)) {
1158
+ content = fs.readFileSync(fullPath, "utf-8");
1159
+ }
1160
+ else {
1161
+ console.warn(`[kb-rpc] kb.get: file missing on disk for doc id=${id} path=${doc.filePath}`);
1162
+ }
1163
+ return {
1164
+ id: doc.id,
1165
+ name: doc.name,
1166
+ filePath: doc.filePath,
1167
+ sizeBytes: doc.sizeBytes,
1168
+ chunkCount: doc.chunkCount,
1169
+ uploadedBy: doc.uploadedBy,
1170
+ createdAt: doc.createdAt?.toISOString(),
1171
+ updatedAt: doc.updatedAt?.toISOString(),
1172
+ content,
1173
+ };
1174
+ });
1175
+ methods.set("kb.upload", async (params, context) => {
1176
+ requireAdmin(context);
1177
+ if (!knowledgeDocRepo)
1178
+ throw new Error("Database not available");
1179
+ const name = params.name;
1180
+ const content = params.content;
1181
+ if (!name)
1182
+ throw new Error("Missing required param: name");
1183
+ if (!content)
1184
+ throw new Error("Missing required param: content");
1185
+ // Size limit: 5MB
1186
+ const MAX_CONTENT_SIZE = 5 * 1024 * 1024;
1187
+ const sizeBytes = Buffer.byteLength(content, "utf-8");
1188
+ if (sizeBytes > MAX_CONTENT_SIZE) {
1189
+ throw new Error(`Content too large: ${(sizeBytes / 1024 / 1024).toFixed(1)}MB exceeds 5MB limit`);
1190
+ }
1191
+ // Generate unique ID first, then build filename with ID prefix to avoid TOCTOU races
1192
+ const docId = crypto.randomBytes(12).toString("hex");
1193
+ let sanitized = name
1194
+ .replace(/[^a-zA-Z0-9_\-. ]/g, "_")
1195
+ .replace(/\s+/g, "_")
1196
+ .replace(/_+/g, "_")
1197
+ .replace(/^_|_$/g, "");
1198
+ if (!sanitized)
1199
+ sanitized = "document";
1200
+ const baseName = sanitized.endsWith(".md") ? sanitized.slice(0, -3) : sanitized;
1201
+ const filePath = `${baseName}_${docId.slice(0, 8)}.md`;
1202
+ if (!fs.existsSync(knowledgeDir)) {
1203
+ fs.mkdirSync(knowledgeDir, { recursive: true });
1204
+ }
1205
+ const fullPath = resolveUnderDir(knowledgeDir, filePath);
1206
+ // Write file, then insert DB record (clean up file on DB failure)
1207
+ fs.writeFileSync(fullPath, content, "utf-8");
1208
+ try {
1209
+ await knowledgeDocRepo.create({
1210
+ id: docId,
1211
+ name,
1212
+ filePath,
1213
+ sizeBytes,
1214
+ uploadedBy: context.auth?.userId,
1215
+ });
1216
+ }
1217
+ catch (err) {
1218
+ // Clean up orphaned file on DB insert failure
1219
+ try {
1220
+ fs.unlinkSync(fullPath);
1221
+ }
1222
+ catch { /* best-effort cleanup */ }
1223
+ throw err;
1224
+ }
1225
+ console.log(`[kb-rpc] kb.upload: name=${name}, file=${filePath}, size=${sizeBytes}, by=${context.auth?.username}`);
1226
+ // Sync indexer and update chunk count
1227
+ if (knowledgeIndexer) {
1228
+ try {
1229
+ await knowledgeIndexer.sync();
1230
+ const chunkCount = knowledgeIndexer.countChunksByFile(filePath);
1231
+ await knowledgeDocRepo.updateChunkCount(docId, chunkCount);
1232
+ }
1233
+ catch (err) {
1234
+ console.warn("[kb-rpc] Knowledge indexer sync failed:", err);
1235
+ }
1236
+ }
1237
+ return { id: docId, name };
1238
+ });
1239
+ methods.set("kb.delete", async (params, context) => {
1240
+ requireAdmin(context);
1241
+ if (!knowledgeDocRepo)
1242
+ throw new Error("Database not available");
1243
+ const id = params.id;
1244
+ if (!id)
1245
+ throw new Error("Missing required param: id");
1246
+ const doc = await knowledgeDocRepo.getById(id);
1247
+ if (!doc)
1248
+ throw new Error("Document not found");
1249
+ // Delete metadata first (authoritative source of truth)
1250
+ await knowledgeDocRepo.delete(id);
1251
+ console.log(`[kb-rpc] kb.delete: id=${id}, name=${doc.name}, by=${context.auth?.username}`);
1252
+ // Then delete file from disk (best-effort, path traversal protected)
1253
+ try {
1254
+ const fullPath = resolveUnderDir(knowledgeDir, doc.filePath);
1255
+ if (fs.existsSync(fullPath)) {
1256
+ fs.unlinkSync(fullPath);
1257
+ }
1258
+ }
1259
+ catch (err) {
1260
+ console.warn(`[kb-rpc] kb.delete: file cleanup failed for ${doc.filePath}:`, err);
1261
+ }
1262
+ // Sync indexer to remove orphaned chunks
1263
+ if (knowledgeIndexer) {
1264
+ try {
1265
+ await knowledgeIndexer.sync();
1266
+ }
1267
+ catch (err) {
1268
+ console.warn("[kb-rpc] Knowledge indexer sync failed after delete:", err);
1269
+ }
1270
+ }
1271
+ return { ok: true };
1272
+ });
1044
1273
  methods.set("chat.steer", async (params, context) => {
1045
1274
  const userId = requireAuth(context);
1046
1275
  const text = params.text;
@@ -1479,12 +1708,15 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1479
1708
  const userId = requireAuth(context);
1480
1709
  const username = context.auth.username;
1481
1710
  const name = params.name;
1482
- const description = params.description;
1483
1711
  const type = params.type;
1484
1712
  const specs = params.specs;
1485
1713
  const rawScripts = params.scripts;
1486
1714
  if (!name)
1487
1715
  throw new Error("Missing required param: name");
1716
+ // Auto-extract description from specs frontmatter; fall back to explicit param
1717
+ const description = specs
1718
+ ? skillWriter.parseFrontmatter(specs).description || params.description
1719
+ : params.description;
1488
1720
  // Resolve scripts: if content is missing, copy from user uploads directory
1489
1721
  let scripts;
1490
1722
  if (rawScripts && rawScripts.length > 0) {
@@ -1721,28 +1953,34 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1721
1953
  const specs = params.specs;
1722
1954
  const rawScripts = params.scripts;
1723
1955
  // Resolve scripts: if content is missing, try DB then uploads dir then existing skill files
1956
+ // NOTE: an explicit empty array means "delete all scripts" — do NOT fall back to existing
1724
1957
  let scripts;
1725
- if (rawScripts && rawScripts.length > 0) {
1726
- const uploadsDir = path.join(skillsDir, "user", userId, "uploads");
1727
- let existingFiles = null;
1728
- if (skillContentRepo) {
1729
- existingFiles = await skillContentRepo.read(skillId, "working");
1958
+ if (Array.isArray(rawScripts)) {
1959
+ if (rawScripts.length === 0) {
1960
+ scripts = [];
1730
1961
  }
1731
- const existingScriptsMap = new Map((existingFiles?.scripts ?? []).map((s) => [s.name, s.content]));
1732
- scripts = rawScripts.map((s) => {
1733
- if (s.content)
1734
- return { name: s.name, content: s.content };
1735
- // Try existing skill scripts
1736
- const existing = existingScriptsMap.get(s.name);
1737
- if (existing)
1738
- return { name: s.name, content: existing };
1739
- // Try uploads directory
1740
- const uploadPath = path.join(uploadsDir, s.name);
1741
- if (fs.existsSync(uploadPath)) {
1742
- return { name: s.name, content: fs.readFileSync(uploadPath, "utf-8") };
1962
+ else {
1963
+ const uploadsDir = path.join(skillsDir, "user", userId, "uploads");
1964
+ let existingFiles = null;
1965
+ if (skillContentRepo) {
1966
+ existingFiles = await skillContentRepo.read(skillId, "working");
1743
1967
  }
1744
- throw new Error(`Script "${s.name}" content not found`);
1745
- });
1968
+ const existingScriptsMap = new Map((existingFiles?.scripts ?? []).map((s) => [s.name, s.content]));
1969
+ scripts = rawScripts.map((s) => {
1970
+ if (s.content)
1971
+ return { name: s.name, content: s.content };
1972
+ // Try existing skill scripts
1973
+ const existing = existingScriptsMap.get(s.name);
1974
+ if (existing)
1975
+ return { name: s.name, content: existing };
1976
+ // Try uploads directory
1977
+ const uploadPath = path.join(uploadsDir, s.name);
1978
+ if (fs.existsSync(uploadPath)) {
1979
+ return { name: s.name, content: fs.readFileSync(uploadPath, "utf-8") };
1980
+ }
1981
+ throw new Error(`Script "${s.name}" content not found`);
1982
+ });
1983
+ }
1746
1984
  }
1747
1985
  // Save content to DB
1748
1986
  if (skillContentRepo) {
@@ -1756,8 +1994,15 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1756
1994
  }
1757
1995
  // Update DB metadata (name and dirName are immutable after creation)
1758
1996
  const updates = {};
1759
- if (params.description !== undefined)
1997
+ // Auto-extract description from specs frontmatter
1998
+ if (specs) {
1999
+ const extracted = skillWriter.parseFrontmatter(specs).description;
2000
+ if (extracted)
2001
+ updates.description = extracted;
2002
+ }
2003
+ else if (params.description !== undefined) {
1760
2004
  updates.description = params.description;
2005
+ }
1761
2006
  if (params.type)
1762
2007
  updates.type = params.type;
1763
2008
  if (params.labels !== undefined) {
@@ -1800,6 +2045,11 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1800
2045
  // Clean up version records
1801
2046
  if (skillVersionRepo)
1802
2047
  await skillVersionRepo.deleteForSkill(skillId);
2048
+ // Clean up orphaned notifications (approval/contribution requests)
2049
+ if (notifRepo) {
2050
+ await notifRepo.dismissByTypeAndRelatedId("skill_review_requested", skillId);
2051
+ await notifRepo.dismissByTypeAndRelatedId("contribution_review_requested", skillId);
2052
+ }
1803
2053
  // Delete from DB (CASCADE deletes skill_contents)
1804
2054
  await skillRepo.deleteById(skillId);
1805
2055
  // Notify reload
@@ -1849,9 +2099,12 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1849
2099
  const meta = await skillRepo.getById(skillId);
1850
2100
  if (!meta)
1851
2101
  throw new Error("Skill not found");
1852
- // Personal skills: only author can view diffs
2102
+ // Personal skills: only author or reviewer can view diffs
1853
2103
  if (meta.scope === "personal" && meta.authorId !== userId) {
1854
- throw new Error("Skill not found");
2104
+ const isReviewer = context.auth?.username === "admin" ||
2105
+ (permRepo ? await permRepo.hasPermission(userId, "skill_reviewer") : false);
2106
+ if (!isReviewer)
2107
+ throw new Error("Skill not found");
1855
2108
  }
1856
2109
  /** Build a unified diff string for specs + all scripts between two SkillFiles */
1857
2110
  function buildFullDiff(oldFiles, newFiles, oldPrefix, newPrefix) {
@@ -2342,14 +2595,60 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2342
2595
  const description = params.description;
2343
2596
  const schedule = params.schedule;
2344
2597
  const status = params.status ?? "active";
2598
+ const skillId = params.skillId;
2345
2599
  const existingId = params.id;
2346
2600
  const workspaceId = params.workspaceId;
2601
+ // ── Validation chain ──────────────────────────
2602
+ // 1. Syntax validation
2603
+ parseCronExpression(schedule);
2604
+ // 2. Ownership check on update
2605
+ let existingJob = null;
2606
+ if (existingId) {
2607
+ existingJob = await configRepo.getCronJobById(existingId);
2608
+ if (!existingJob)
2609
+ throw new Error("Job not found");
2610
+ if (existingJob.userId !== userId)
2611
+ throw new Error("Forbidden");
2612
+ }
2613
+ // 3. Interval check (skip if updating without changing schedule)
2614
+ const scheduleChanged = !existingJob || existingJob.schedule !== schedule;
2615
+ if (scheduleChanged) {
2616
+ const { avg, min } = getAverageIntervalMs(schedule, CRON_LIMITS.INTERVAL_SAMPLE_COUNT);
2617
+ if (min < CRON_LIMITS.ABSOLUTE_MIN_GAP_MS) {
2618
+ const floorMin = Math.round(CRON_LIMITS.ABSOLUTE_MIN_GAP_MS / 60_000);
2619
+ throw new Error(`Schedule has burst firing: minimum gap between executions must be at least ${floorMin} minutes`);
2620
+ }
2621
+ if (avg < CRON_LIMITS.MIN_INTERVAL_MS) {
2622
+ const limitMin = Math.round(CRON_LIMITS.MIN_INTERVAL_MS / 60_000);
2623
+ throw new Error(`Schedule interval too short: minimum ${limitMin} minutes between executions`);
2624
+ }
2625
+ }
2626
+ // 4. Active job quota
2627
+ if (status === "active") {
2628
+ const activeCount = await configRepo.countActiveJobsByUser(userId);
2629
+ // If updating an already-active job, it's already counted
2630
+ const alreadyCounted = existingJob?.status === "active" ? 1 : 0;
2631
+ if (activeCount - alreadyCounted >= CRON_LIMITS.MAX_ACTIVE_JOBS_PER_USER) {
2632
+ throw new Error(`Active job limit reached (max ${CRON_LIMITS.MAX_ACTIVE_JOBS_PER_USER})`);
2633
+ }
2634
+ }
2635
+ // 5. Skill ownership validation
2636
+ if (skillId && skillRepo) {
2637
+ const skill = await skillRepo.getById(skillId);
2638
+ if (!skill)
2639
+ throw new Error(`Skill not found: ${skillId}`);
2640
+ // Allow builtin + team skills for everyone; personal skills only for the author
2641
+ if (skill.scope === "personal" && skill.authorId !== userId) {
2642
+ throw new Error("Forbidden: cannot use another user's personal skill");
2643
+ }
2644
+ }
2645
+ // ── Persist ───────────────────────────────────
2347
2646
  const id = await configRepo.saveCronJob(userId, {
2348
2647
  id: existingId,
2349
2648
  name,
2350
2649
  description,
2351
2650
  schedule,
2352
- skillId: params.skillId,
2651
+ skillId,
2353
2652
  status,
2354
2653
  workspaceId: workspaceId ?? null,
2355
2654
  });
@@ -2360,7 +2659,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2360
2659
  else {
2361
2660
  cronService.addOrUpdate({
2362
2661
  id, userId, name, description: description ?? null, schedule, status,
2363
- skillId: params.skillId ?? null, assignedTo: null,
2662
+ skillId: skillId ?? null, assignedTo: null,
2364
2663
  lastRunAt: null, lastResult: null, lockedBy: null, lockedAt: null,
2365
2664
  workspaceId: workspaceId ?? null,
2366
2665
  });
@@ -2408,6 +2707,24 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2408
2707
  throw new Error("Job not found");
2409
2708
  if (job.userId !== userId)
2410
2709
  throw new Error("Forbidden");
2710
+ // ── Rate-limit checks when activating ──────────
2711
+ if (status === "active" && job.status !== "active") {
2712
+ // Interval check (prevents re-activating a high-frequency job)
2713
+ const { avg, min } = getAverageIntervalMs(job.schedule, CRON_LIMITS.INTERVAL_SAMPLE_COUNT);
2714
+ if (min < CRON_LIMITS.ABSOLUTE_MIN_GAP_MS) {
2715
+ const floorMin = Math.round(CRON_LIMITS.ABSOLUTE_MIN_GAP_MS / 60_000);
2716
+ throw new Error(`Schedule has burst firing: minimum gap between executions must be at least ${floorMin} minutes`);
2717
+ }
2718
+ if (avg < CRON_LIMITS.MIN_INTERVAL_MS) {
2719
+ const limitMin = Math.round(CRON_LIMITS.MIN_INTERVAL_MS / 60_000);
2720
+ throw new Error(`Schedule interval too short: minimum ${limitMin} minutes between executions`);
2721
+ }
2722
+ // Active job quota
2723
+ const activeCount = await configRepo.countActiveJobsByUser(userId);
2724
+ if (activeCount >= CRON_LIMITS.MAX_ACTIVE_JOBS_PER_USER) {
2725
+ throw new Error(`Active job limit reached (max ${CRON_LIMITS.MAX_ACTIVE_JOBS_PER_USER})`);
2726
+ }
2727
+ }
2411
2728
  // Update status
2412
2729
  await configRepo.saveCronJob(userId, {
2413
2730
  id,
@@ -2631,6 +2948,11 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2631
2948
  // Clean up votes
2632
2949
  if (voteRepo)
2633
2950
  await voteRepo.deleteForSkill(skillId);
2951
+ // Clean up orphaned notifications (approval/contribution requests for deleted team skill)
2952
+ if (notifRepo) {
2953
+ await notifRepo.dismissByTypeAndRelatedId("skill_review_requested", skillId);
2954
+ await notifRepo.dismissByTypeAndRelatedId("contribution_review_requested", skillId);
2955
+ }
2634
2956
  // Notify author
2635
2957
  const authorId = sourceSkill?.authorId ?? meta.authorId;
2636
2958
  if (notifRepo && authorId) {
@@ -2696,11 +3018,14 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2696
3018
  throw new Error("Missing required param: id");
2697
3019
  if (!skillReviewRepo)
2698
3020
  return { reviews: [] };
2699
- // Personal skills: only author can view reviews
3021
+ // Personal skills: only author or reviewer can view reviews
2700
3022
  if (skillRepo) {
2701
3023
  const meta = await skillRepo.getById(skillId);
2702
3024
  if (meta && meta.scope === "personal" && meta.authorId !== userId) {
2703
- throw new Error("Skill not found");
3025
+ const isReviewer = context.auth?.username === "admin" ||
3026
+ (permRepo ? await permRepo.hasPermission(userId, "skill_reviewer") : false);
3027
+ if (!isReviewer)
3028
+ throw new Error("Skill not found");
2704
3029
  }
2705
3030
  }
2706
3031
  const reviews = await skillReviewRepo.listForSkill(skillId);
@@ -2734,6 +3059,11 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2734
3059
  contributionStatus: "none",
2735
3060
  stagingVersion: 0,
2736
3061
  });
3062
+ // Clean up orphaned notifications (approval/contribution requests)
3063
+ if (notifRepo) {
3064
+ await notifRepo.dismissByTypeAndRelatedId("skill_review_requested", skillId);
3065
+ await notifRepo.dismissByTypeAndRelatedId("contribution_review_requested", skillId);
3066
+ }
2737
3067
  notifySkillReload(userId);
2738
3068
  return { status: "withdrawn", wasNew: false };
2739
3069
  });
@@ -2947,7 +3277,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2947
3277
  if (!name)
2948
3278
  throw new Error("Missing required param: name");
2949
3279
  if (type === "kubeconfig") {
2950
- throw new Error("Kubeconfig credentials are now managed via Environments. Use userEnvConfig.set instead.");
3280
+ throw new Error("Kubeconfig credentials are now managed via Clusters. Use userClusterConfig.set instead.");
2951
3281
  }
2952
3282
  if (!type || !CREDENTIAL_TYPES.includes(type)) {
2953
3283
  throw new Error(`Invalid credential type. Must be one of: ${CREDENTIAL_TYPES.join(", ")}`);
@@ -2998,12 +3328,12 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2998
3328
  return { status: "deleted" };
2999
3329
  });
3000
3330
  // ─────────────────────────────────────────────────
3001
- // Environment Methods (admin-only)
3331
+ // Cluster Methods (admin-only)
3002
3332
  // ─────────────────────────────────────────────────
3003
- methods.set("environment.list", async (_params, context) => {
3333
+ methods.set("cluster.list", async (_params, context) => {
3004
3334
  const userId = requireAuth(context);
3005
- if (!envRepo)
3006
- return { environments: [], isAdmin: false };
3335
+ if (!clusterRepo)
3336
+ return { clusters: [], isAdmin: false };
3007
3337
  const isAdmin = isAdminUser(context);
3008
3338
  // Check testOnly
3009
3339
  let isTestOnly = false;
@@ -3011,20 +3341,22 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3011
3341
  const dbUser = await userRepo.getById(userId);
3012
3342
  isTestOnly = dbUser?.testOnly ?? false;
3013
3343
  }
3014
- const allEnvs = await envRepo.list();
3015
- const visibleEnvs = isTestOnly ? allEnvs.filter((e) => e.isTest) : allEnvs;
3344
+ const allClusters = await clusterRepo.list();
3345
+ const visibleClusters = isTestOnly ? allClusters.filter((e) => e.isTest) : allClusters;
3016
3346
  // Fetch user's kubeconfig status
3017
- const userConfigs = userEnvConfigRepo ? await userEnvConfigRepo.listForUser(userId) : [];
3018
- const configMap = new Map(userConfigs.map((c) => [c.envId, c]));
3347
+ const userConfigs = userClusterConfigRepo ? await userClusterConfigRepo.listForUser(userId) : [];
3348
+ const configMap = new Map(userConfigs.map((c) => [c.clusterId, c]));
3019
3349
  return {
3020
3350
  isAdmin,
3021
- environments: visibleEnvs.map((e) => ({
3351
+ clusters: visibleClusters.map((e) => ({
3022
3352
  id: e.id,
3023
3353
  name: e.name,
3354
+ infraContext: e.infraContext ?? null,
3024
3355
  isTest: e.isTest,
3025
3356
  apiServer: e.apiServer,
3026
3357
  allowedServers: e.allowedServers ? e.allowedServers.split(",").map((s) => s.trim()).filter(Boolean) : [],
3027
3358
  hasDefaultKubeconfig: !!e.defaultKubeconfig,
3359
+ debugImage: e.debugImage ?? null,
3028
3360
  hasUserKubeconfig: configMap.has(e.id),
3029
3361
  userConfigUpdatedAt: configMap.get(e.id)?.updatedAt?.toISOString?.() ?? configMap.get(e.id)?.updatedAt ?? null,
3030
3362
  createdBy: e.createdBy,
@@ -3033,16 +3365,18 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3033
3365
  })),
3034
3366
  };
3035
3367
  });
3036
- methods.set("environment.create", async (params, context) => {
3368
+ methods.set("cluster.create", async (params, context) => {
3037
3369
  const userId = requireAdmin(context);
3038
- if (!envRepo)
3370
+ if (!clusterRepo)
3039
3371
  throw new Error("Database not available");
3040
3372
  const name = params.name;
3373
+ const infraContext = params.infraContext ?? null;
3041
3374
  const isTest = params.isTest;
3042
3375
  const apiServer = params.apiServer;
3043
3376
  const rawAllowedServers = params.allowedServers;
3044
3377
  const allowedServers = Array.isArray(rawAllowedServers) ? rawAllowedServers.join(", ") : rawAllowedServers;
3045
3378
  const defaultKubeconfig = params.defaultKubeconfig;
3379
+ const debugImage = params.debugImage?.trim() || null;
3046
3380
  if (!name)
3047
3381
  throw new Error("Missing required param: name");
3048
3382
  if (!apiServer)
@@ -3062,20 +3396,21 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3062
3396
  if (defaultKubeconfig && !isTest) {
3063
3397
  throw new Error("defaultKubeconfig can only be set for test environments");
3064
3398
  }
3065
- const id = await envRepo.save({ name, isTest, apiServer, allowedServers: allowedServers || null, defaultKubeconfig: defaultKubeconfig ?? null }, userId);
3399
+ const id = await clusterRepo.save({ name, infraContext, isTest, apiServer, allowedServers: allowedServers || null, defaultKubeconfig: defaultKubeconfig ?? null, debugImage }, userId);
3066
3400
  return { id, name };
3067
3401
  });
3068
- methods.set("environment.update", async (params, context) => {
3402
+ methods.set("cluster.update", async (params, context) => {
3069
3403
  requireAdmin(context);
3070
- if (!envRepo)
3404
+ if (!clusterRepo)
3071
3405
  throw new Error("Database not available");
3072
3406
  const id = params.id;
3073
3407
  if (!id)
3074
3408
  throw new Error("Missing required param: id");
3075
- const existing = await envRepo.getById(id);
3409
+ const existing = await clusterRepo.getById(id);
3076
3410
  if (!existing)
3077
- throw new Error("Environment not found");
3411
+ throw new Error("Cluster not found");
3078
3412
  const name = params.name ?? existing.name;
3413
+ const infraContext = params.infraContext !== undefined ? params.infraContext : existing.infraContext;
3079
3414
  const apiServer = params.apiServer ?? existing.apiServer;
3080
3415
  const isTest = params.isTest !== undefined ? params.isTest : existing.isTest;
3081
3416
  const rawAllowed = params.allowedServers;
@@ -3083,6 +3418,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3083
3418
  ? (Array.isArray(rawAllowed) ? rawAllowed.join(", ") : rawAllowed)
3084
3419
  : existing.allowedServers;
3085
3420
  let defaultKubeconfig = params.defaultKubeconfig !== undefined ? params.defaultKubeconfig : existing.defaultKubeconfig;
3421
+ const debugImage = params.debugImage !== undefined ? (params.debugImage?.trim() || null) : existing.debugImage;
3086
3422
  if (!apiServer?.trim()) {
3087
3423
  throw new Error("apiServer must be a non-empty string");
3088
3424
  }
@@ -3106,10 +3442,10 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3106
3442
  throw new Error("defaultKubeconfig can only be set for test environments");
3107
3443
  }
3108
3444
  const oldApiServer = existing.apiServer;
3109
- await envRepo.save({ id, name, isTest, apiServer, allowedServers, defaultKubeconfig });
3445
+ await clusterRepo.save({ id, name, infraContext, isTest, apiServer, allowedServers, defaultKubeconfig, debugImage });
3110
3446
  // If apiServer changed, invalidate mismatched user kubeconfigs
3111
- if (userEnvConfigRepo && apiServer !== oldApiServer) {
3112
- const fullConfigs = await userEnvConfigRepo.listFullForEnv(id);
3447
+ if (userClusterConfigRepo && apiServer !== oldApiServer) {
3448
+ const fullConfigs = await userClusterConfigRepo.listFullForCluster(id);
3113
3449
  const affectedUserIds = new Set();
3114
3450
  for (const cfg of fullConfigs) {
3115
3451
  try {
@@ -3118,13 +3454,13 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3118
3454
  const servers = clusters.map((c) => c.cluster?.server).filter(Boolean);
3119
3455
  const matches = servers.some((s) => apiServerHostMatch(s, apiServer));
3120
3456
  if (!matches) {
3121
- await userEnvConfigRepo.remove(cfg.userId, id);
3457
+ await userClusterConfigRepo.remove(cfg.userId, id);
3122
3458
  affectedUserIds.add(cfg.userId);
3123
3459
  }
3124
3460
  }
3125
3461
  catch {
3126
3462
  // If kubeconfig can't be parsed, remove it as invalid
3127
- await userEnvConfigRepo.remove(cfg.userId, id);
3463
+ await userClusterConfigRepo.remove(cfg.userId, id);
3128
3464
  affectedUserIds.add(cfg.userId);
3129
3465
  }
3130
3466
  }
@@ -3135,25 +3471,25 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3135
3471
  }
3136
3472
  return { status: "updated" };
3137
3473
  });
3138
- methods.set("environment.delete", async (params, context) => {
3474
+ methods.set("cluster.delete", async (params, context) => {
3139
3475
  requireAdmin(context);
3140
- if (!envRepo)
3476
+ if (!clusterRepo)
3141
3477
  throw new Error("Database not available");
3142
3478
  const id = params.id;
3143
3479
  if (!id)
3144
3480
  throw new Error("Missing required param: id");
3145
- const existing = await envRepo.getById(id);
3481
+ const existing = await clusterRepo.getById(id);
3146
3482
  if (!existing)
3147
- throw new Error("Environment not found");
3483
+ throw new Error("Cluster not found");
3148
3484
  // Collect affected users before cleanup
3149
3485
  const affectedUserIds = new Set();
3150
- if (userEnvConfigRepo) {
3151
- const envConfigs = await userEnvConfigRepo.listForEnv(id);
3486
+ if (userClusterConfigRepo) {
3487
+ const envConfigs = await userClusterConfigRepo.listForCluster(id);
3152
3488
  for (const c of envConfigs)
3153
3489
  affectedUserIds.add(c.userId);
3154
- await userEnvConfigRepo.removeAllForEnv(id);
3490
+ await userClusterConfigRepo.removeAllForCluster(id);
3155
3491
  }
3156
- await envRepo.delete(id);
3492
+ await clusterRepo.delete(id);
3157
3493
  // Push updated credentials to all affected users
3158
3494
  for (const uid of affectedUserIds) {
3159
3495
  pushCredentialsToUser(uid);
@@ -3161,54 +3497,54 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3161
3497
  return { status: "deleted" };
3162
3498
  });
3163
3499
  // ─────────────────────────────────────────────────
3164
- // User Environment Config Methods (kubeconfig upload)
3500
+ // User Cluster Config Methods (kubeconfig upload)
3165
3501
  // ─────────────────────────────────────────────────
3166
- methods.set("userEnvConfig.list", async (_params, context) => {
3502
+ methods.set("userClusterConfig.list", async (_params, context) => {
3167
3503
  const userId = requireAuth(context);
3168
- if (!envRepo || !userEnvConfigRepo)
3504
+ if (!clusterRepo || !userClusterConfigRepo)
3169
3505
  return { configs: [] };
3170
- // Fetch all environments and user's configs
3171
- const allEnvs = await envRepo.list();
3172
- const userConfigs = await userEnvConfigRepo.listForUser(userId);
3506
+ // Fetch all clusters and user's configs
3507
+ const allClusters = await clusterRepo.list();
3508
+ const userConfigs = await userClusterConfigRepo.listForUser(userId);
3173
3509
  // Check if user is testOnly
3174
3510
  let isTestOnly = false;
3175
3511
  if (userRepo) {
3176
3512
  const dbUser = await userRepo.getById(userId);
3177
3513
  isTestOnly = dbUser?.testOnly ?? false;
3178
3514
  }
3179
- // Filter out production environments for testOnly users
3180
- const visibleEnvs = isTestOnly ? allEnvs.filter((e) => e.isTest) : allEnvs;
3181
- const configMap = new Map(userConfigs.map((c) => [c.envId, c]));
3515
+ // Filter out production clusters for testOnly users
3516
+ const visibleClusters = isTestOnly ? allClusters.filter((e) => e.isTest) : allClusters;
3517
+ const configMap = new Map(userConfigs.map((c) => [c.clusterId, c]));
3182
3518
  return {
3183
- configs: visibleEnvs.map((env) => ({
3184
- envId: env.id,
3185
- envName: env.name,
3186
- isTest: env.isTest,
3187
- apiServer: env.apiServer,
3188
- hasKubeconfig: configMap.has(env.id),
3189
- updatedAt: configMap.get(env.id)?.updatedAt ?? null,
3519
+ configs: visibleClusters.map((cls) => ({
3520
+ clusterId: cls.id,
3521
+ clusterName: cls.name,
3522
+ isTest: cls.isTest,
3523
+ apiServer: cls.apiServer,
3524
+ hasKubeconfig: configMap.has(cls.id),
3525
+ updatedAt: configMap.get(cls.id)?.updatedAt ?? null,
3190
3526
  })),
3191
3527
  };
3192
3528
  });
3193
- methods.set("userEnvConfig.set", async (params, context) => {
3529
+ methods.set("userClusterConfig.set", async (params, context) => {
3194
3530
  const userId = requireAuth(context);
3195
- if (!envRepo || !userEnvConfigRepo)
3531
+ if (!clusterRepo || !userClusterConfigRepo)
3196
3532
  throw new Error("Database not available");
3197
- const envId = params.envId;
3533
+ const clusterId = params.clusterId;
3198
3534
  const kubeconfig = params.kubeconfig;
3199
- if (!envId)
3200
- throw new Error("Missing required param: envId");
3535
+ if (!clusterId)
3536
+ throw new Error("Missing required param: clusterId");
3201
3537
  if (!kubeconfig)
3202
3538
  throw new Error("Missing required param: kubeconfig");
3203
- // Fetch environment
3204
- const env = await envRepo.getById(envId);
3205
- if (!env)
3206
- throw new Error("Environment not found");
3539
+ // Fetch cluster
3540
+ const cluster = await clusterRepo.getById(clusterId);
3541
+ if (!cluster)
3542
+ throw new Error("Cluster not found");
3207
3543
  // testOnly user check
3208
3544
  if (userRepo) {
3209
3545
  const dbUser = await userRepo.getById(userId);
3210
- if (dbUser?.testOnly && !env.isTest) {
3211
- throw new Error("Test-only users cannot configure production environments");
3546
+ if (dbUser?.testOnly && !cluster.isTest) {
3547
+ throw new Error("Test-only users cannot configure production clusters");
3212
3548
  }
3213
3549
  }
3214
3550
  // Parse and validate kubeconfig YAML
@@ -3220,23 +3556,23 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3220
3556
  throw new Error("Invalid kubeconfig: YAML parse error");
3221
3557
  }
3222
3558
  // Validate apiServer appears in kubeconfig clusters
3223
- const clusters = parsed?.clusters ?? [];
3224
- const servers = clusters.map((c) => c.cluster?.server).filter(Boolean);
3225
- if (!servers.some((s) => apiServerHostMatch(s, env.apiServer))) {
3226
- throw new Error(`Kubeconfig does not contain a cluster matching apiServer "${env.apiServer}"`);
3559
+ const kubeClusters = parsed?.clusters ?? [];
3560
+ const servers = kubeClusters.map((c) => c.cluster?.server).filter(Boolean);
3561
+ if (!servers.some((s) => apiServerHostMatch(s, cluster.apiServer))) {
3562
+ throw new Error(`Kubeconfig does not contain a cluster matching apiServer "${cluster.apiServer}"`);
3227
3563
  }
3228
- await userEnvConfigRepo.set(userId, envId, kubeconfig);
3564
+ await userClusterConfigRepo.set(userId, clusterId, kubeconfig);
3229
3565
  pushCredentialsToUser(userId);
3230
3566
  return { status: "saved" };
3231
3567
  });
3232
- methods.set("userEnvConfig.remove", async (params, context) => {
3568
+ methods.set("userClusterConfig.remove", async (params, context) => {
3233
3569
  const userId = requireAuth(context);
3234
- if (!userEnvConfigRepo)
3570
+ if (!userClusterConfigRepo)
3235
3571
  throw new Error("Database not available");
3236
- const envId = params.envId;
3237
- if (!envId)
3238
- throw new Error("Missing required param: envId");
3239
- await userEnvConfigRepo.remove(userId, envId);
3572
+ const clusterId = params.clusterId;
3573
+ if (!clusterId)
3574
+ throw new Error("Missing required param: clusterId");
3575
+ await userClusterConfigRepo.remove(userId, clusterId);
3240
3576
  pushCredentialsToUser(userId);
3241
3577
  return { status: "removed" };
3242
3578
  });
@@ -3313,14 +3649,14 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3313
3649
  throw new Error("Test-only users cannot create production workspaces");
3314
3650
  }
3315
3651
  }
3316
- // If changing to "test", verify all bound environments are test
3317
- if (params.envType === "test" && envRepo && workspaceRepo) {
3318
- const boundEnvIds = await workspaceRepo.getEnvironments(id);
3319
- if (boundEnvIds.length > 0) {
3320
- const boundEnvs = await envRepo.listByIds(boundEnvIds);
3321
- const nonTest = boundEnvs.filter((e) => !e.isTest);
3652
+ // If changing to "test", verify all bound clusters are test
3653
+ if (params.envType === "test" && clusterRepo && workspaceRepo) {
3654
+ const boundClusterIds = await workspaceRepo.getClusters(id);
3655
+ if (boundClusterIds.length > 0) {
3656
+ const boundClusters = await clusterRepo.listByIds(boundClusterIds);
3657
+ const nonTest = boundClusters.filter((e) => !e.isTest);
3322
3658
  if (nonTest.length > 0) {
3323
- throw new Error(`Cannot change to test type: workspace has ${nonTest.length} non-test environment(s) bound. Unbind them first.`);
3659
+ throw new Error(`Cannot change to test type: workspace has ${nonTest.length} non-test cluster(s) bound. Unbind them first.`);
3324
3660
  }
3325
3661
  }
3326
3662
  }
@@ -3357,25 +3693,25 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3357
3693
  const ws = await workspaceRepo.getById(id);
3358
3694
  if (!ws || ws.userId !== userId)
3359
3695
  throw new Error("Workspace not found");
3360
- const [wsSkills, wsTools, wsCreds, wsEnvIds] = await Promise.all([
3696
+ const [wsSkills, wsTools, wsCreds, wsClusterIds] = await Promise.all([
3361
3697
  workspaceRepo.getSkills(id),
3362
3698
  workspaceRepo.getTools(id),
3363
3699
  workspaceRepo.getCredentials(id),
3364
- workspaceRepo.getEnvironments(id),
3700
+ workspaceRepo.getClusters(id),
3365
3701
  ]);
3366
- // Fetch full environment details for bound environments
3367
- let envDetails = [];
3368
- if (envRepo && wsEnvIds.length > 0) {
3369
- const envs = await envRepo.listByIds(wsEnvIds);
3370
- envDetails = envs.map((e) => ({ id: e.id, name: e.name, isTest: e.isTest, apiServer: e.apiServer }));
3702
+ // Fetch full cluster details for bound clusters
3703
+ let clusterDetails = [];
3704
+ if (clusterRepo && wsClusterIds.length > 0) {
3705
+ const cls = await clusterRepo.listByIds(wsClusterIds);
3706
+ clusterDetails = cls.map((e) => ({ id: e.id, name: e.name, isTest: e.isTest, apiServer: e.apiServer }));
3371
3707
  }
3372
3708
  return {
3373
3709
  workspace: ws,
3374
3710
  skills: wsSkills,
3375
3711
  tools: wsTools,
3376
3712
  credentials: wsCreds,
3377
- environments: wsEnvIds,
3378
- environmentDetails: envDetails,
3713
+ clusters: wsClusterIds,
3714
+ clusterDetails,
3379
3715
  };
3380
3716
  });
3381
3717
  methods.set("workspace.setSkills", async (params, context) => {
@@ -3446,44 +3782,44 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3446
3782
  pushCredentialsToUser(userId);
3447
3783
  return { status: "updated" };
3448
3784
  });
3449
- methods.set("workspace.setEnvironments", async (params, context) => {
3785
+ methods.set("workspace.setClusters", async (params, context) => {
3450
3786
  const userId = requireAuth(context);
3451
3787
  if (!workspaceRepo)
3452
3788
  throw new Error("Database not available");
3453
3789
  const workspaceId = params.workspaceId;
3454
- const envIds = params.envIds;
3455
- if (!workspaceId || !Array.isArray(envIds))
3790
+ const clusterIds = params.clusterIds;
3791
+ if (!workspaceId || !Array.isArray(clusterIds))
3456
3792
  throw new Error("Missing required params");
3457
3793
  const ws = await workspaceRepo.getById(workspaceId);
3458
3794
  if (!ws || ws.userId !== userId)
3459
3795
  throw new Error("Workspace not found");
3460
- // Validate: if workspace is test, all bound environments must be test
3461
- if (envIds.length > 0 && !envRepo) {
3462
- throw new Error("Environment database not available");
3463
- }
3464
- if (envIds.length > 0 && envRepo) {
3465
- const envs = await envRepo.listByIds(envIds);
3466
- if (envs.length !== envIds.length) {
3467
- throw new Error("One or more environments not found");
3796
+ // Validate: if workspace is test, all bound clusters must be test
3797
+ if (clusterIds.length > 0 && !clusterRepo) {
3798
+ throw new Error("Cluster database not available");
3799
+ }
3800
+ if (clusterIds.length > 0 && clusterRepo) {
3801
+ const cls = await clusterRepo.listByIds(clusterIds);
3802
+ if (cls.length !== clusterIds.length) {
3803
+ throw new Error("One or more clusters not found");
3468
3804
  }
3469
3805
  if (ws.envType === "test") {
3470
- const nonTest = envs.filter((e) => !e.isTest);
3806
+ const nonTest = cls.filter((e) => !e.isTest);
3471
3807
  if (nonTest.length > 0) {
3472
- throw new Error("Test workspaces can only bind test environments");
3808
+ throw new Error("Test workspaces can only bind test clusters");
3473
3809
  }
3474
3810
  }
3475
3811
  // testOnly user check
3476
3812
  if (userRepo) {
3477
3813
  const dbUser = await userRepo.getById(userId);
3478
3814
  if (dbUser?.testOnly) {
3479
- const nonTest = envs.filter((e) => !e.isTest);
3815
+ const nonTest = cls.filter((e) => !e.isTest);
3480
3816
  if (nonTest.length > 0) {
3481
- throw new Error("Test-only users cannot bind production environments");
3817
+ throw new Error("Test-only users cannot bind production clusters");
3482
3818
  }
3483
3819
  }
3484
3820
  }
3485
3821
  }
3486
- await workspaceRepo.setEnvironments(workspaceId, envIds);
3822
+ await workspaceRepo.setClusters(workspaceId, clusterIds);
3487
3823
  // Push updated credentials to running AgentBox for this workspace
3488
3824
  pushCredentialsToUser(userId);
3489
3825
  return { status: "updated" };
@@ -3512,7 +3848,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3512
3848
  /**
3513
3849
  * Build credential payload for a workspace.
3514
3850
  *
3515
- * Kubeconfigs: sourced from environment-bound userEnvConfigs (NOT credentials table).
3851
+ * Kubeconfigs: sourced from cluster-bound userClusterConfigs (NOT credentials table).
3516
3852
  * Other credentials: from credentials table, filtered by workspace envType.
3517
3853
  * - prod workspace: all workspace-linked credentials
3518
3854
  * - test workspace: NO non-kubeconfig credentials (SSH, API tokens hidden)
@@ -3520,11 +3856,6 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3520
3856
  * Returns data only — does NOT write to disk. Agentbox materializes files locally.
3521
3857
  */
3522
3858
  async function buildCredentialPayload(userId, workspaceId, isDefault) {
3523
- // Ensure user agent-data directory exists (used as subPath mount for user data)
3524
- const agentDataDir = path.join(skillsDir, "user", userId, "agent-data");
3525
- if (!fs.existsSync(agentDataDir)) {
3526
- fs.mkdirSync(agentDataDir, { recursive: true });
3527
- }
3528
3859
  const manifest = [];
3529
3860
  const files = [];
3530
3861
  // ── Step 1: Determine workspace envType ──
@@ -3534,55 +3865,59 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3534
3865
  if (ws)
3535
3866
  envType = ws.envType ?? "prod";
3536
3867
  }
3537
- // ── Step 2: Kubeconfigs from environments ──
3538
- if (envRepo && userEnvConfigRepo) {
3539
- // Default workspace: all environments; non-default: only workspace-bound
3540
- let envs;
3868
+ // ── Step 2: Kubeconfigs from clusters ──
3869
+ if (clusterRepo && userClusterConfigRepo) {
3870
+ // Default workspace: all clusters; non-default: only workspace-bound
3871
+ let clusterList;
3541
3872
  if (isDefault) {
3542
- envs = await envRepo.list();
3873
+ clusterList = await clusterRepo.list();
3543
3874
  }
3544
3875
  else if (workspaceRepo) {
3545
- const boundEnvIds = await workspaceRepo.getEnvironments(workspaceId);
3546
- envs = boundEnvIds.length > 0 ? await envRepo.listByIds(boundEnvIds) : [];
3876
+ const boundClusterIds = await workspaceRepo.getClusters(workspaceId);
3877
+ clusterList = boundClusterIds.length > 0 ? await clusterRepo.listByIds(boundClusterIds) : [];
3547
3878
  }
3548
3879
  else {
3549
- envs = [];
3880
+ clusterList = [];
3550
3881
  }
3551
- if (envs.length > 0) {
3552
- for (const env of envs) {
3553
- // Runtime filter: test workspace skips prod environments
3554
- if (envType === "test" && !env.isTest)
3882
+ if (clusterList.length > 0) {
3883
+ for (const cls of clusterList) {
3884
+ // Runtime filter: test workspace skips prod clusters
3885
+ if (envType === "test" && !cls.isTest)
3555
3886
  continue;
3556
- // Get user's kubeconfig for this environment
3557
- const userConfig = await userEnvConfigRepo.get(userId, env.id);
3887
+ // Get user's kubeconfig for this cluster
3888
+ const userConfig = await userClusterConfigRepo.get(userId, cls.id);
3558
3889
  let kubeconfigContent = userConfig?.kubeconfig ?? null;
3559
- // Fallback to defaultKubeconfig for test environments
3560
- if (!kubeconfigContent && env.isTest && env.defaultKubeconfig) {
3561
- kubeconfigContent = env.defaultKubeconfig;
3890
+ // Fallback to defaultKubeconfig for test clusters
3891
+ if (!kubeconfigContent && cls.isTest && cls.defaultKubeconfig) {
3892
+ kubeconfigContent = cls.defaultKubeconfig;
3562
3893
  }
3563
3894
  if (kubeconfigContent) {
3564
- const safeName = env.name.replace(/[^a-zA-Z0-9_-]/g, "_");
3895
+ const safeName = cls.name.replace(/[^a-zA-Z0-9_-]/g, "_");
3565
3896
  const filename = `${safeName}.kubeconfig`;
3566
3897
  files.push({ name: filename, content: kubeconfigContent });
3567
3898
  const fileNames = [filename];
3568
3899
  let metadata;
3569
3900
  try {
3570
3901
  const kc = yaml.load(kubeconfigContent);
3571
- const clusters = kc?.clusters ?? [];
3902
+ const kcClusters = kc?.clusters ?? [];
3572
3903
  const contexts = kc?.contexts ?? [];
3573
3904
  metadata = {
3574
- clusters: clusters.map((c) => ({ name: c.name, server: c.cluster?.server })),
3905
+ clusters: kcClusters.map((c) => ({ name: c.name, server: c.cluster?.server })),
3575
3906
  contexts: contexts.map((c) => ({ name: c.name, cluster: c.context?.cluster, namespace: c.context?.namespace })),
3576
3907
  currentContext: kc?.["current-context"],
3908
+ ...(cls.debugImage ? { debugImage: cls.debugImage } : {}),
3577
3909
  };
3578
3910
  }
3579
3911
  catch {
3580
- // ignore parse errors
3912
+ // ignore parse errors — still attach debugImage if available
3913
+ if (cls.debugImage) {
3914
+ metadata = { debugImage: cls.debugImage };
3915
+ }
3581
3916
  }
3582
3917
  manifest.push({
3583
- name: env.name,
3918
+ name: cls.name,
3584
3919
  type: "kubeconfig",
3585
- description: `Kubeconfig for environment: ${env.name}`,
3920
+ description: cls.infraContext || `Kubeconfig for cluster: ${cls.name}`,
3586
3921
  files: fileNames,
3587
3922
  ...(metadata ? { metadata } : {}),
3588
3923
  });
@@ -3768,12 +4103,12 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3768
4103
  credentials[c.type] = (credentials[c.type] || 0) + 1;
3769
4104
  }
3770
4105
  }
3771
- if (envRepo && userEnvConfigRepo) {
3772
- const allEnvs = await envRepo.list();
4106
+ if (clusterRepo && userClusterConfigRepo) {
4107
+ const allEnvs = await clusterRepo.list();
3773
4108
  let kubeconfigCount = 0;
3774
4109
  for (const env of allEnvs) {
3775
4110
  // Count if user has a personal kubeconfig, OR if it's a test env with a default kubeconfig
3776
- const userConfig = await userEnvConfigRepo.get(userId, env.id);
4111
+ const userConfig = await userClusterConfigRepo.get(userId, env.id);
3777
4112
  if (userConfig?.kubeconfig || (env.isTest && env.defaultKubeconfig)) {
3778
4113
  kubeconfigCount++;
3779
4114
  }