siclaw 0.1.1 → 0.1.2

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 (267) hide show
  1. package/README.md +74 -114
  2. package/dist/agentbox/gateway-client.d.ts +2 -1
  3. package/dist/agentbox/gateway-client.js +6 -2
  4. package/dist/agentbox/gateway-client.js.map +1 -1
  5. package/dist/agentbox/http-server.js +184 -19
  6. package/dist/agentbox/http-server.js.map +1 -1
  7. package/dist/agentbox/resource-handlers.d.ts +1 -0
  8. package/dist/agentbox/resource-handlers.js +23 -23
  9. package/dist/agentbox/resource-handlers.js.map +1 -1
  10. package/dist/agentbox/session.js +85 -5
  11. package/dist/agentbox/session.js.map +1 -1
  12. package/dist/agentbox-main.d.ts +2 -1
  13. package/dist/agentbox-main.js +65 -18
  14. package/dist/agentbox-main.js.map +1 -1
  15. package/dist/cli-credentials.d.ts +1 -0
  16. package/dist/cli-credentials.js +109 -0
  17. package/dist/cli-credentials.js.map +1 -0
  18. package/dist/cli-first-run.d.ts +11 -0
  19. package/dist/cli-first-run.js +99 -0
  20. package/dist/cli-first-run.js.map +1 -0
  21. package/dist/cli-main.js +33 -11
  22. package/dist/cli-main.js.map +1 -1
  23. package/dist/cli-setup.d.ts +5 -11
  24. package/dist/cli-setup.js +12 -225
  25. package/dist/cli-setup.js.map +1 -1
  26. package/dist/core/agent-factory.d.ts +4 -0
  27. package/dist/core/agent-factory.js +102 -151
  28. package/dist/core/agent-factory.js.map +1 -1
  29. package/dist/core/config.d.ts +10 -3
  30. package/dist/core/config.js +11 -95
  31. package/dist/core/config.js.map +1 -1
  32. package/dist/core/extensions/deep-investigation.d.ts +2 -1
  33. package/dist/core/extensions/deep-investigation.js +144 -24
  34. package/dist/core/extensions/deep-investigation.js.map +1 -1
  35. package/dist/core/extensions/setup.d.ts +8 -0
  36. package/dist/core/extensions/setup.js +669 -0
  37. package/dist/core/extensions/setup.js.map +1 -0
  38. package/dist/core/llm-proxy.js +7 -3
  39. package/dist/core/llm-proxy.js.map +1 -1
  40. package/dist/core/mcp-client.d.ts +0 -10
  41. package/dist/core/mcp-client.js +0 -65
  42. package/dist/core/mcp-client.js.map +1 -1
  43. package/dist/core/prompt.d.ts +1 -1
  44. package/dist/core/prompt.js +42 -5
  45. package/dist/core/prompt.js.map +1 -1
  46. package/dist/core/provider-presets.d.ts +14 -0
  47. package/dist/core/provider-presets.js +81 -0
  48. package/dist/core/provider-presets.js.map +1 -0
  49. package/dist/cron/cron-coordinator.d.ts +2 -0
  50. package/dist/cron/cron-coordinator.js +46 -14
  51. package/dist/cron/cron-coordinator.js.map +1 -1
  52. package/dist/cron/cron-executor.js +33 -8
  53. package/dist/cron/cron-executor.js.map +1 -1
  54. package/dist/cron/cron-scheduler.d.ts +1 -1
  55. package/dist/cron/gateway-client.d.ts +5 -0
  56. package/dist/cron/gateway-client.js +43 -8
  57. package/dist/cron/gateway-client.js.map +1 -1
  58. package/dist/cron-main.js +39 -9
  59. package/dist/cron-main.js.map +1 -1
  60. package/dist/gateway/agentbox/client.d.ts +11 -0
  61. package/dist/gateway/agentbox/client.js +18 -0
  62. package/dist/gateway/agentbox/client.js.map +1 -1
  63. package/dist/gateway/agentbox/k8s-spawner.d.ts +11 -2
  64. package/dist/gateway/agentbox/k8s-spawner.js +95 -52
  65. package/dist/gateway/agentbox/k8s-spawner.js.map +1 -1
  66. package/dist/gateway/agentbox/local-spawner.d.ts +1 -1
  67. package/dist/gateway/agentbox/local-spawner.js +4 -2
  68. package/dist/gateway/agentbox/local-spawner.js.map +1 -1
  69. package/dist/gateway/agentbox/manager.d.ts +0 -10
  70. package/dist/gateway/agentbox/manager.js +11 -30
  71. package/dist/gateway/agentbox/manager.js.map +1 -1
  72. package/dist/gateway/agentbox/types.d.ts +6 -4
  73. package/dist/gateway/cron/cron-service.d.ts +49 -0
  74. package/dist/gateway/cron/cron-service.js +259 -0
  75. package/dist/gateway/cron/cron-service.js.map +1 -0
  76. package/dist/gateway/db/init-schema.js +44 -0
  77. package/dist/gateway/db/init-schema.js.map +1 -1
  78. package/dist/gateway/db/migrate-sqlite.js +73 -4
  79. package/dist/gateway/db/migrate-sqlite.js.map +1 -1
  80. package/dist/gateway/db/repositories/chat-repo.d.ts +56 -2
  81. package/dist/gateway/db/repositories/chat-repo.js +132 -2
  82. package/dist/gateway/db/repositories/chat-repo.js.map +1 -1
  83. package/dist/gateway/db/repositories/config-repo.d.ts +31 -2
  84. package/dist/gateway/db/repositories/config-repo.js +57 -7
  85. package/dist/gateway/db/repositories/config-repo.js.map +1 -1
  86. package/dist/gateway/db/repositories/env-repo.d.ts +14 -0
  87. package/dist/gateway/db/repositories/env-repo.js +15 -2
  88. package/dist/gateway/db/repositories/env-repo.js.map +1 -1
  89. package/dist/gateway/db/repositories/model-config-repo.js +6 -5
  90. package/dist/gateway/db/repositories/model-config-repo.js.map +1 -1
  91. package/dist/gateway/db/repositories/skill-repo.d.ts +0 -5
  92. package/dist/gateway/db/repositories/skill-review-repo.d.ts +1 -0
  93. package/dist/gateway/db/repositories/skill-review-repo.js +4 -1
  94. package/dist/gateway/db/repositories/skill-review-repo.js.map +1 -1
  95. package/dist/gateway/db/repositories/skill-version-repo.js +0 -1
  96. package/dist/gateway/db/repositories/skill-version-repo.js.map +1 -1
  97. package/dist/gateway/db/repositories/system-config-repo.d.ts +1 -1
  98. package/dist/gateway/db/repositories/system-config-repo.js +2 -1
  99. package/dist/gateway/db/repositories/system-config-repo.js.map +1 -1
  100. package/dist/gateway/db/repositories/user-env-config-repo.d.ts +13 -0
  101. package/dist/gateway/db/repositories/user-env-config-repo.js +11 -0
  102. package/dist/gateway/db/repositories/user-env-config-repo.js.map +1 -1
  103. package/dist/gateway/db/repositories/workspace-repo.d.ts +3 -2
  104. package/dist/gateway/db/repositories/workspace-repo.js +6 -2
  105. package/dist/gateway/db/repositories/workspace-repo.js.map +1 -1
  106. package/dist/gateway/db/schema-mysql.d.ts +473 -51
  107. package/dist/gateway/db/schema-mysql.js +35 -4
  108. package/dist/gateway/db/schema-mysql.js.map +1 -1
  109. package/dist/gateway/db/schema-sqlite.d.ts +522 -57
  110. package/dist/gateway/db/schema-sqlite.js +38 -6
  111. package/dist/gateway/db/schema-sqlite.js.map +1 -1
  112. package/dist/gateway/db/schema.d.ts +471 -51
  113. package/dist/gateway/db/schema.js +1 -1
  114. package/dist/gateway/db/schema.js.map +1 -1
  115. package/dist/gateway/metrics-aggregator.d.ts +65 -0
  116. package/dist/gateway/metrics-aggregator.js +244 -0
  117. package/dist/gateway/metrics-aggregator.js.map +1 -0
  118. package/dist/gateway/plugins/channel-bridge.d.ts +4 -1
  119. package/dist/gateway/plugins/channel-bridge.js +78 -86
  120. package/dist/gateway/plugins/channel-bridge.js.map +1 -1
  121. package/dist/gateway/rpc-methods.d.ts +4 -2
  122. package/dist/gateway/rpc-methods.js +852 -166
  123. package/dist/gateway/rpc-methods.js.map +1 -1
  124. package/dist/gateway/security/cert-manager.d.ts +2 -2
  125. package/dist/gateway/security/cert-manager.js +4 -2
  126. package/dist/gateway/security/cert-manager.js.map +1 -1
  127. package/dist/gateway/server.d.ts +4 -8
  128. package/dist/gateway/server.js +297 -261
  129. package/dist/gateway/server.js.map +1 -1
  130. package/dist/gateway/skills/file-writer.js +17 -11
  131. package/dist/gateway/skills/file-writer.js.map +1 -1
  132. package/dist/gateway/skills/script-evaluator.js +12 -9
  133. package/dist/gateway/skills/script-evaluator.js.map +1 -1
  134. package/dist/gateway/web/dist/assets/index-0p17ZeTP.js +740 -0
  135. package/dist/gateway/web/dist/assets/index-9eP6nPUq.js +741 -0
  136. package/dist/gateway/web/dist/assets/index-9eP6nPUq.js.map +1 -0
  137. package/dist/gateway/web/dist/assets/index-DyowBCEj.css +1 -0
  138. package/dist/gateway/web/dist/assets/index-PDK5JJDO.css +1 -0
  139. package/dist/gateway/web/dist/index.html +2 -2
  140. package/dist/gateway-main.js +27 -10
  141. package/dist/gateway-main.js.map +1 -1
  142. package/dist/memory/embeddings.js +5 -4
  143. package/dist/memory/embeddings.js.map +1 -1
  144. package/dist/memory/indexer.d.ts +23 -3
  145. package/dist/memory/indexer.js +235 -23
  146. package/dist/memory/indexer.js.map +1 -1
  147. package/dist/memory/schema.js +15 -1
  148. package/dist/memory/schema.js.map +1 -1
  149. package/dist/memory/types.d.ts +18 -0
  150. package/dist/memory/types.js +6 -1
  151. package/dist/memory/types.js.map +1 -1
  152. package/dist/shared/detect-language.d.ts +12 -0
  153. package/dist/shared/detect-language.js +78 -0
  154. package/dist/shared/detect-language.js.map +1 -0
  155. package/dist/shared/diagnostic-events.d.ts +70 -0
  156. package/dist/shared/diagnostic-events.js +38 -0
  157. package/dist/shared/diagnostic-events.js.map +1 -0
  158. package/dist/shared/local-collector.d.ts +56 -0
  159. package/dist/shared/local-collector.js +284 -0
  160. package/dist/shared/local-collector.js.map +1 -0
  161. package/dist/shared/metrics-types.d.ts +64 -0
  162. package/dist/shared/metrics-types.js +25 -0
  163. package/dist/shared/metrics-types.js.map +1 -0
  164. package/dist/shared/metrics.d.ts +19 -0
  165. package/dist/shared/metrics.js +185 -0
  166. package/dist/shared/metrics.js.map +1 -0
  167. package/dist/shared/path-utils.d.ts +15 -0
  168. package/dist/shared/path-utils.js +23 -0
  169. package/dist/shared/path-utils.js.map +1 -0
  170. package/dist/shared/retry.d.ts +35 -0
  171. package/dist/shared/retry.js +61 -0
  172. package/dist/shared/retry.js.map +1 -0
  173. package/dist/tools/command-sets.d.ts +18 -2
  174. package/dist/tools/command-sets.js +207 -32
  175. package/dist/tools/command-sets.js.map +1 -1
  176. package/dist/tools/command-validator.d.ts +56 -0
  177. package/dist/tools/command-validator.js +357 -0
  178. package/dist/tools/command-validator.js.map +1 -0
  179. package/dist/tools/create-skill.js +26 -1
  180. package/dist/tools/create-skill.js.map +1 -1
  181. package/dist/tools/credential-list.js +1 -23
  182. package/dist/tools/credential-list.js.map +1 -1
  183. package/dist/tools/credential-manager.d.ts +98 -0
  184. package/dist/tools/credential-manager.js +313 -0
  185. package/dist/tools/credential-manager.js.map +1 -0
  186. package/dist/tools/deep-search/engine.js +184 -127
  187. package/dist/tools/deep-search/engine.js.map +1 -1
  188. package/dist/tools/deep-search/prompts.d.ts +10 -2
  189. package/dist/tools/deep-search/prompts.js +37 -36
  190. package/dist/tools/deep-search/prompts.js.map +1 -1
  191. package/dist/tools/deep-search/schemas.d.ts +87 -0
  192. package/dist/tools/deep-search/schemas.js +85 -0
  193. package/dist/tools/deep-search/schemas.js.map +1 -0
  194. package/dist/tools/deep-search/sub-agent.d.ts +21 -0
  195. package/dist/tools/deep-search/sub-agent.js +153 -4
  196. package/dist/tools/deep-search/sub-agent.js.map +1 -1
  197. package/dist/tools/deep-search/tool.js +1 -0
  198. package/dist/tools/deep-search/tool.js.map +1 -1
  199. package/dist/tools/deep-search/types.d.ts +2 -0
  200. package/dist/tools/deep-search/types.js.map +1 -1
  201. package/dist/tools/dp-tools.js +29 -5
  202. package/dist/tools/dp-tools.js.map +1 -1
  203. package/dist/tools/exec-utils.d.ts +85 -0
  204. package/dist/tools/exec-utils.js +294 -0
  205. package/dist/tools/exec-utils.js.map +1 -0
  206. package/dist/tools/fork-skill.js +14 -2
  207. package/dist/tools/fork-skill.js.map +1 -1
  208. package/dist/tools/investigation-feedback.d.ts +3 -0
  209. package/dist/tools/investigation-feedback.js +71 -0
  210. package/dist/tools/investigation-feedback.js.map +1 -0
  211. package/dist/tools/manage-schedule.js +16 -6
  212. package/dist/tools/manage-schedule.js.map +1 -1
  213. package/dist/tools/netns-script.js +27 -281
  214. package/dist/tools/netns-script.js.map +1 -1
  215. package/dist/tools/node-exec.d.ts +2 -14
  216. package/dist/tools/node-exec.js +18 -225
  217. package/dist/tools/node-exec.js.map +1 -1
  218. package/dist/tools/node-script.js +14 -168
  219. package/dist/tools/node-script.js.map +1 -1
  220. package/dist/tools/pod-exec.d.ts +1 -1
  221. package/dist/tools/pod-exec.js +10 -26
  222. package/dist/tools/pod-exec.js.map +1 -1
  223. package/dist/tools/pod-nsenter-exec.js +21 -225
  224. package/dist/tools/pod-nsenter-exec.js.map +1 -1
  225. package/dist/tools/pod-script.js +10 -19
  226. package/dist/tools/pod-script.js.map +1 -1
  227. package/dist/tools/restricted-bash.d.ts +1 -17
  228. package/dist/tools/restricted-bash.js +38 -252
  229. package/dist/tools/restricted-bash.js.map +1 -1
  230. package/dist/tools/run-skill.d.ts +3 -1
  231. package/dist/tools/run-skill.js +21 -1
  232. package/dist/tools/run-skill.js.map +1 -1
  233. package/dist/tools/script-resolver.d.ts +3 -1
  234. package/dist/tools/script-resolver.js +74 -30
  235. package/dist/tools/script-resolver.js.map +1 -1
  236. package/dist/tools/update-skill.js +17 -6
  237. package/dist/tools/update-skill.js.map +1 -1
  238. package/package.json +4 -2
  239. package/siclaw.mjs +10 -1
  240. package/skills/core/cluster-events/SKILL.md +1 -1
  241. package/skills/core/deep-investigation/SKILL.md +11 -0
  242. package/skills/core/deployment-rollout-debug/SKILL.md +1 -1
  243. package/skills/core/dns-debug/SKILL.md +1 -0
  244. package/skills/core/meta.json +12 -1
  245. package/skills/core/networkpolicy-debug/SKILL.md +332 -0
  246. package/skills/core/node-logs/scripts/get-node-logs.sh +19 -9
  247. package/skills/core/pod-pending-debug/SKILL.md +1 -0
  248. package/skills/core/quota-debug/SKILL.md +203 -0
  249. package/skills/core/service-debug/SKILL.md +1 -0
  250. package/skills/core/statefulset-debug/SKILL.md +280 -0
  251. package/skills/core/volcano-diagnose-pod/SKILL.md +196 -0
  252. package/skills/core/volcano-diagnose-pod/scripts/diagnose-pod.sh +175 -0
  253. package/skills/core/volcano-gang-scheduling/SKILL.md +299 -0
  254. package/skills/core/volcano-job-diagnose/SKILL.md +319 -0
  255. package/skills/core/volcano-job-diagnose/scripts/diagnose-job.sh +253 -0
  256. package/skills/core/volcano-node-resources/SKILL.md +334 -0
  257. package/skills/core/volcano-node-resources/scripts/get-node-resources.sh +281 -0
  258. package/skills/core/volcano-queue-diagnose/SKILL.md +294 -0
  259. package/skills/core/volcano-queue-diagnose/scripts/diagnose-queue.sh +283 -0
  260. package/skills/core/volcano-resource-insufficient/SKILL.md +315 -0
  261. package/skills/core/volcano-scheduler-config/SKILL.md +371 -0
  262. package/skills/core/volcano-scheduler-config/scripts/get-scheduler-config.sh +297 -0
  263. package/skills/core/volcano-scheduler-logs/SKILL.md +241 -0
  264. package/skills/core/volcano-scheduler-logs/scripts/get-scheduler-logs.sh +159 -0
  265. package/skills/platform/create-skill/SKILL.md +35 -3
  266. package/skills/platform/manage-skill/SKILL.md +9 -2
  267. package/skills/platform/update-skill/SKILL.md +17 -6
@@ -21,6 +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
26
  import { getLabelsForSkill, batchGetLabels, listAllLabels } from "./skill-labels.js";
25
27
  import { McpServerRepository } from "./db/repositories/mcp-server-repo.js";
26
28
  import { SkillFileWriter } from "./skills/file-writer.js";
@@ -29,10 +31,11 @@ import { ScriptEvaluator } from "./skills/script-evaluator.js";
29
31
  import { SkillVersionRepository } from "./db/repositories/skill-version-repo.js";
30
32
  import { createTwoFilesPatch } from "diff";
31
33
  import yaml from "js-yaml";
32
- import { notifyCronService as notifyCronServiceImpl } from "./cron/notify.js";
33
34
  import { buildSkillBundle } from "./skills/skill-bundle.js";
34
35
  import { buildRedactionConfig, redactText } from "./output-redactor.js";
35
36
  import { RESOURCE_DESCRIPTORS } from "../shared/resource-sync.js";
37
+ import { sql, gte, sum, count } from "drizzle-orm";
38
+ import { sessionStats } from "./db/schema.js";
36
39
  function requireAuth(context) {
37
40
  const userId = context.auth?.userId;
38
41
  if (!userId)
@@ -41,11 +44,32 @@ function requireAuth(context) {
41
44
  }
42
45
  function requireAdmin(context) {
43
46
  const userId = requireAuth(context);
44
- if (context.auth?.username !== "admin")
47
+ if (!isAdminUser(context))
45
48
  throw new Error("Forbidden: admin access required");
46
49
  return userId;
47
50
  }
48
- export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, activePromptUsers, agentBoxTlsOptions, resourceNotifier) {
51
+ function isAdminUser(context) {
52
+ return context.auth?.username === "admin";
53
+ }
54
+ /**
55
+ * Compare two Kubernetes API server URLs by hostname (and port if present).
56
+ * Prevents substring bypass (e.g. "evil-https://real-server:6443" matching "real-server:6443").
57
+ */
58
+ function apiServerHostMatch(kubeconfigServer, envApiServer) {
59
+ try {
60
+ const a = new URL(kubeconfigServer);
61
+ const b = new URL(envApiServer.includes("://") ? envApiServer : `https://${envApiServer}`);
62
+ // Require explicit port on the env side — environments without port are legacy and must be updated
63
+ if (!b.port)
64
+ return false;
65
+ return a.hostname === b.hostname && (a.port || "443") === b.port;
66
+ }
67
+ catch {
68
+ // If URL parsing fails, reject the match — don't fall back to loose comparison
69
+ return false;
70
+ }
71
+ }
72
+ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, activePromptUsers, agentBoxTlsOptions, resourceNotifier, metricsAggregator, cronService) {
49
73
  const methods = new Map();
50
74
  // Initialize repositories (null-safe — methods check before use)
51
75
  const chatRepo = db ? new ChatRepository(db) : null;
@@ -63,13 +87,15 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
63
87
  const sysConfigRepo = db ? new SystemConfigRepository(db) : null;
64
88
  const mcpRepo = db ? new McpServerRepository(db) : null;
65
89
  const skillContentRepo = db ? new SkillContentRepository(db) : null;
90
+ const envRepo = db ? new EnvironmentRepository(db) : null;
91
+ const userEnvConfigRepo = db ? new UserEnvConfigRepository(db) : null;
66
92
  const scriptEvaluator = new ScriptEvaluator(modelConfigRepo);
67
- /** Resolve workspaceId for a session from DB, falling back to "default" */
93
+ /** Resolve workspaceId for a session from DB */
68
94
  async function resolveSessionWorkspace(sessionId) {
69
95
  if (!chatRepo)
70
- return "default";
96
+ return undefined;
71
97
  const session = await chatRepo.getSession(sessionId);
72
- return session?.workspaceId ?? "default";
98
+ return session?.workspaceId ?? undefined;
73
99
  }
74
100
  /** Find an AgentBox handle for a user, trying session workspace first, then any active box */
75
101
  async function findAgentBoxForSession(userId, sessionId) {
@@ -120,6 +146,56 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
120
146
  console.warn(`[resource-notify] All skill reload failed:`, err.message);
121
147
  });
122
148
  }
149
+ /**
150
+ * Push updated credentials to all active AgentBoxes for a user (fire-and-forget).
151
+ * Builds the credential payload per workspace and POSTs to each box's /api/reload-credentials.
152
+ */
153
+ function pushCredentialsToUser(userId) {
154
+ if (!workspaceRepo)
155
+ return;
156
+ // Async fire-and-forget
157
+ (async () => {
158
+ // Local mode: handles carry workspaceId from cache key
159
+ let handles = agentBoxManager.getForUser(userId);
160
+ if (handles.length === 0) {
161
+ // K8s mode: find running pods via list(), workspace label provides workspaceId
162
+ const allBoxes = await agentBoxManager.list();
163
+ const userBoxes = allBoxes.filter((b) => b.userId === userId && b.status === "running" && b.endpoint);
164
+ if (userBoxes.length === 0)
165
+ return;
166
+ for (const box of userBoxes) {
167
+ handles.push({
168
+ boxId: box.boxId,
169
+ userId: box.userId,
170
+ endpoint: box.endpoint,
171
+ workspaceId: box.workspaceId,
172
+ });
173
+ }
174
+ }
175
+ if (handles.length === 0)
176
+ return;
177
+ for (const handle of handles) {
178
+ try {
179
+ // Resolve workspace for this box
180
+ const wsId = handle.workspaceId;
181
+ const ws = wsId
182
+ ? (await workspaceRepo.getById(wsId)) ?? (await workspaceRepo.getOrCreateDefault(userId))
183
+ : await workspaceRepo.getOrCreateDefault(userId);
184
+ if (!ws)
185
+ continue;
186
+ const payload = await buildCredentialPayload(userId, ws.id, ws.isDefault);
187
+ const client = new AgentBoxClient(handle.endpoint, 15000, agentBoxTlsOptions);
188
+ await client.reloadCredentials(payload);
189
+ console.log(`[credential-push] Pushed credentials to box=${handle.boxId} ws=${ws.name} (${payload.files.length} files)`);
190
+ }
191
+ catch (err) {
192
+ console.warn(`[credential-push] Failed for box=${handle.boxId}:`, err instanceof Error ? err.message : err);
193
+ }
194
+ }
195
+ })().catch((err) => {
196
+ console.warn(`[credential-push] Unexpected error for userId=${userId}:`, err instanceof Error ? err.message : err);
197
+ });
198
+ }
123
199
  // Initialize skills dir
124
200
  skillWriter.init()
125
201
  .then(async () => {
@@ -269,7 +345,9 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
269
345
  workspace = null;
270
346
  }
271
347
  }
272
- const effectiveWorkspaceId = workspace?.id ?? "default";
348
+ if (!workspace)
349
+ throw new Error("Failed to resolve workspace");
350
+ const effectiveWorkspaceId = workspace.id;
273
351
  // Ensure session exists in DB
274
352
  if (chatRepo) {
275
353
  if (sessionId) {
@@ -296,7 +374,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
296
374
  });
297
375
  }
298
376
  if (!sessionId)
299
- sessionId = "default";
377
+ throw new Error("Failed to create session");
300
378
  // Forward model selection and brain type from frontend
301
379
  // Use workspace default model if user didn't specify one
302
380
  const modelProvider = params.modelProvider ?? workspace?.configJson?.defaultModel?.provider;
@@ -331,9 +409,12 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
331
409
  }
332
410
  }
333
411
  // Get or create AgentBox (per workspace)
412
+ // Encode workspace envType into cert so Gateway trusts the cert, not AgentBox's self-declaration
413
+ const podEnv = (workspace?.envType === "test" ? "test" : "prod");
334
414
  const handle = await agentBoxManager.getOrCreate(userId, effectiveWorkspaceId, {
335
415
  workspaceId: effectiveWorkspaceId,
336
416
  allowedTools,
417
+ podEnv,
337
418
  });
338
419
  const client = new AgentBoxClient(handle.endpoint, 30000, agentBoxTlsOptions);
339
420
  // Send prompt
@@ -365,7 +446,9 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
365
446
  // Async SSE processing
366
447
  (async () => {
367
448
  let assistantContent = "";
368
- let pendingToolInput = ""; // Capture tool input from start event for DB persistence
449
+ // Map keyed by toolName to handle parallel tool calls correctly
450
+ const pendingToolInputs = new Map();
451
+ const pendingToolStartTimes = new Map();
369
452
  let sseEventCount = 0;
370
453
  const sseStartTime = Date.now();
371
454
  // Mark user as having an active prompt so WS teardown won't kill the pod
@@ -391,15 +474,32 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
391
474
  .map((c) => c.text ?? "")
392
475
  .join("") ?? "";
393
476
  const toolName = eventData.toolName || "tool";
477
+ // Audit: determine outcome
478
+ let outcome = "success";
479
+ if (toolResult?.details?.blocked) {
480
+ outcome = "blocked";
481
+ }
482
+ else if (toolResult?.details?.error) {
483
+ outcome = "error";
484
+ }
485
+ const startTime = pendingToolStartTimes.get(toolName);
486
+ const durationMs = startTime != null
487
+ ? Date.now() - startTime
488
+ : undefined;
489
+ const toolInput = pendingToolInputs.get(toolName) || "";
394
490
  dbMessageId = await chatRepo.appendMessage({
395
491
  sessionId: result.sessionId,
396
492
  role: "tool",
397
493
  content: redactText(text, redactionConfig),
398
494
  toolName,
399
- toolInput: pendingToolInput ? redactText(pendingToolInput, redactionConfig) : undefined,
495
+ toolInput: toolInput ? redactText(toolInput, redactionConfig) : undefined,
496
+ userId,
497
+ outcome,
498
+ durationMs,
400
499
  });
401
500
  await chatRepo.incrementMessageCount(result.sessionId);
402
- pendingToolInput = "";
501
+ pendingToolInputs.delete(toolName);
502
+ pendingToolStartTimes.delete(toolName);
403
503
  }
404
504
  // Forward event to frontend via sendToUser (targets all WS connections
405
505
  // for this user, so reconnected sessions also receive live events)
@@ -469,9 +569,11 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
469
569
  }
470
570
  }
471
571
  else if (eventType === "tool_execution_start") {
472
- // Capture tool input for DB persistence
572
+ // Capture tool input and start time for DB persistence (keyed by toolName for parallel calls)
573
+ const startToolName = eventData.toolName || "tool";
473
574
  const args = eventData.args;
474
- pendingToolInput = args ? JSON.stringify(args) : "";
575
+ pendingToolInputs.set(startToolName, args ? JSON.stringify(args) : "");
576
+ pendingToolStartTimes.set(startToolName, Date.now());
475
577
  }
476
578
  // tool_execution_end already handled above
477
579
  }
@@ -839,20 +941,20 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
839
941
  })),
840
942
  };
841
943
  }
842
- // Fallback: read local file (CLI / no-DB mode)
843
- const mcpConfigPath = path.resolve(process.cwd(), "config", "mcp-servers.json");
944
+ // Fallback: read from settings.json mcpServers (CLI / no-DB mode)
844
945
  try {
845
- const raw = fs.readFileSync(mcpConfigPath, "utf-8");
846
- const config = JSON.parse(raw);
946
+ const { loadConfig } = await import("../core/config.js");
947
+ const config = loadConfig();
847
948
  const servers = [];
848
949
  for (const [name, serverConfig] of Object.entries(config.mcpServers ?? {})) {
950
+ const cfg = serverConfig;
849
951
  servers.push({
850
952
  id: name,
851
953
  name,
852
- url: serverConfig.url,
853
- transport: serverConfig.transport ?? (serverConfig.url ? "streamable-http" : "stdio"),
954
+ url: cfg.url,
955
+ transport: cfg.transport ?? (cfg.url ? "streamable-http" : "stdio"),
854
956
  enabled: true,
855
- source: "file",
957
+ source: "settings",
856
958
  });
857
959
  }
858
960
  return { servers };
@@ -1001,12 +1103,14 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1001
1103
  const userId = requireAuth(context);
1002
1104
  const sessionId = params.sessionId;
1003
1105
  const snapKey = sessionId ? `${userId}:${sessionId}` : userId;
1106
+ const streamKey = sessionId ? `${userId}:${sessionId}` : undefined;
1107
+ const promptActive = streamKey ? activeStreams.has(streamKey) : false;
1004
1108
  const snap = dpProgressSnapshots.get(snapKey);
1005
1109
  if (!snap || Date.now() - snap.updatedAt > 600_000) {
1006
1110
  dpProgressSnapshots.delete(snapKey);
1007
- return { events: null };
1111
+ return { events: null, promptActive };
1008
1112
  }
1009
- return { sessionId: snap.sessionId, events: snap.events };
1113
+ return { sessionId: snap.sessionId, events: snap.events, promptActive };
1010
1114
  });
1011
1115
  methods.set("chat.history", async (params, context) => {
1012
1116
  const userId = requireAuth(context);
@@ -1056,34 +1160,27 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1056
1160
  // ─────────────────────────────────────────────────
1057
1161
  methods.set("session.list", async (params, context) => {
1058
1162
  const userId = requireAuth(context);
1163
+ if (!chatRepo)
1164
+ throw new Error("Database not available");
1059
1165
  const workspaceId = params?.workspaceId;
1060
- if (chatRepo) {
1061
- const rows = await chatRepo.listSessions(userId, 20, workspaceId);
1062
- return {
1063
- sessions: rows.map((s) => ({
1064
- key: s.id,
1065
- title: s.title,
1066
- preview: s.preview,
1067
- createdAt: s.createdAt?.toISOString(),
1068
- lastActiveAt: s.lastActiveAt?.toISOString(),
1069
- messageCount: s.messageCount,
1070
- })),
1071
- };
1072
- }
1073
- // Fallback: get from AgentBox
1074
- const handle = await agentBoxManager.getAsync(userId, workspaceId ?? "default");
1075
- if (!handle)
1076
- return { sessions: [] };
1077
- const client = new AgentBoxClient(handle.endpoint, 30000, agentBoxTlsOptions);
1078
- return client.listSessions();
1166
+ const rows = await chatRepo.listSessions(userId, 20, workspaceId);
1167
+ return {
1168
+ sessions: rows.map((s) => ({
1169
+ key: s.id,
1170
+ title: s.title,
1171
+ preview: s.preview,
1172
+ createdAt: s.createdAt?.toISOString(),
1173
+ lastActiveAt: s.lastActiveAt?.toISOString(),
1174
+ messageCount: s.messageCount,
1175
+ })),
1176
+ };
1079
1177
  });
1080
1178
  methods.set("session.create", async (_params, context) => {
1081
1179
  const userId = requireAuth(context);
1082
- if (chatRepo) {
1083
- const session = await chatRepo.createSession(userId);
1084
- return { sessionId: session.id, sessionKey: session.id };
1085
- }
1086
- return { sessionId: "default", sessionKey: "default" };
1180
+ if (!chatRepo)
1181
+ throw new Error("Database not available");
1182
+ const session = await chatRepo.createSession(userId);
1183
+ return { sessionId: session.id, sessionKey: session.id };
1087
1184
  });
1088
1185
  methods.set("session.delete", async (params, context) => {
1089
1186
  const userId = requireAuth(context);
@@ -1100,7 +1197,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1100
1197
  // ─────────────────────────────────────────────────
1101
1198
  methods.set("box.status", async (params, context) => {
1102
1199
  const userId = requireAuth(context);
1103
- const workspaceId = params?.workspaceId ?? "default";
1200
+ const workspaceId = params?.workspaceId;
1104
1201
  const handle = await agentBoxManager.getAsync(userId, workspaceId);
1105
1202
  if (!handle)
1106
1203
  return { boxStatus: "not_created" };
@@ -1937,12 +2034,17 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1937
2034
  await skillRepo.update(skillId, updates);
1938
2035
  }
1939
2036
  // 5. AI script review using staged files
2037
+ // Clear old reviews first so the UI shows "in progress" while the new review runs
2038
+ if (skillReviewRepo) {
2039
+ await skillReviewRepo.deleteAiReviewsForSkill(skillId);
2040
+ }
1940
2041
  let stagedFiles = null;
1941
2042
  if (skillContentRepo) {
1942
2043
  stagedFiles = await skillContentRepo.read(skillId, "staging");
1943
2044
  }
1944
- if (stagedFiles?.scripts?.length) {
1945
- triggerScriptReview(skillId, meta.name, stagedFiles.scripts, stagedFiles.specs).catch(console.error);
2045
+ if (stagedFiles?.scripts?.length || stagedFiles?.specs) {
2046
+ // Review both scripts and specs — specs contain command templates the agent will follow
2047
+ triggerScriptReview(skillId, meta.name, stagedFiles?.scripts ?? [], stagedFiles?.specs).catch(console.error);
1946
2048
  }
1947
2049
  // 6. Notify reviewers (only on first submit to avoid flooding)
1948
2050
  if (!isPending) {
@@ -1978,14 +2080,17 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1978
2080
  if (currentReviewStatus !== "pending") {
1979
2081
  throw new Error("This skill is not pending review");
1980
2082
  }
1981
- // Record reviewer decision
2083
+ // Record reviewer decision — inherit riskLevel from the latest AI review if available
1982
2084
  if (skillReviewRepo) {
2085
+ const reviews = await skillReviewRepo.listForSkill(skillId);
2086
+ const aiReview = reviews.find((r) => r.reviewerType === "ai");
2087
+ const riskLevel = aiReview?.riskLevel ?? "low";
1983
2088
  await skillReviewRepo.create({
1984
2089
  skillId,
1985
2090
  version: meta.version,
1986
2091
  reviewerType: "admin",
1987
2092
  reviewerId,
1988
- riskLevel: "low",
2093
+ riskLevel,
1989
2094
  summary: reason || (decision === "approve" ? "Approved by reviewer" : "Rejected by reviewer"),
1990
2095
  findings: [],
1991
2096
  decision,
@@ -2205,19 +2310,27 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2205
2310
  // ─────────────────────────────────────────────────
2206
2311
  // Cron Job Methods
2207
2312
  // ─────────────────────────────────────────────────
2208
- /** Notify all cron instances of job changes (fire-and-forget) */
2209
- function notifyCronService(payload) {
2210
- notifyCronServiceImpl(payload, configRepo);
2211
- }
2212
- methods.set("cron.list", async (_params, context) => {
2313
+ methods.set("cron.list", async (params, context) => {
2213
2314
  const userId = requireAuth(context);
2214
2315
  if (!configRepo)
2215
2316
  return { jobs: [] };
2216
- const rows = await configRepo.listCronJobs(userId);
2317
+ const workspaceId = params.workspaceId;
2318
+ const opts = workspaceId ? { workspaceId } : undefined;
2319
+ const rows = await configRepo.listCronJobs(userId, opts);
2320
+ // Enrich with workspace names
2321
+ const wsIds = [...new Set(rows.map((r) => r.workspaceId).filter(Boolean))];
2322
+ const wsNameMap = new Map();
2323
+ if (wsIds.length > 0 && workspaceRepo) {
2324
+ for (const wsId of wsIds) {
2325
+ const ws = await workspaceRepo.getById(wsId);
2326
+ if (ws)
2327
+ wsNameMap.set(wsId, ws.name);
2328
+ }
2329
+ }
2217
2330
  return {
2218
2331
  jobs: rows.map((r) => ({
2219
2332
  ...r,
2220
- envName: null,
2333
+ workspaceName: r.workspaceId ? (wsNameMap.get(r.workspaceId) ?? null) : null,
2221
2334
  })),
2222
2335
  };
2223
2336
  });
@@ -2229,10 +2342,8 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2229
2342
  const description = params.description;
2230
2343
  const schedule = params.schedule;
2231
2344
  const status = params.status ?? "active";
2232
- // For updates: fetch existing job to get current assignedTo
2233
2345
  const existingId = params.id;
2234
- const existingJob = existingId ? await configRepo.getCronJobById(existingId) : null;
2235
- const envId = params.envId;
2346
+ const workspaceId = params.workspaceId;
2236
2347
  const id = await configRepo.saveCronJob(userId, {
2237
2348
  id: existingId,
2238
2349
  name,
@@ -2240,38 +2351,20 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2240
2351
  schedule,
2241
2352
  skillId: params.skillId,
2242
2353
  status,
2243
- envId: envId ?? null,
2354
+ workspaceId: workspaceId ?? null,
2244
2355
  });
2245
- if (status === "paused") {
2246
- // Pausing just cancel the timer, don't reassign
2247
- notifyCronService({ action: "pause", jobId: id });
2248
- }
2249
- else {
2250
- // Active — keep existing assignment if possible, otherwise assign to least-loaded
2251
- let assignedTo = existingJob?.assignedTo ?? null;
2252
- if (!assignedTo) {
2253
- try {
2254
- const leastLoaded = await configRepo.getLeastLoadedInstance();
2255
- if (leastLoaded) {
2256
- assignedTo = leastLoaded.instanceId;
2257
- }
2258
- }
2259
- catch {
2260
- // No instances available yet — coordinator will pick up unassigned jobs
2261
- }
2262
- }
2263
- if (assignedTo) {
2264
- await configRepo.assignCronJob(id, assignedTo);
2356
+ if (cronService) {
2357
+ if (status === "paused") {
2358
+ cronService.cancel(id);
2265
2359
  }
2266
- notifyCronService({
2267
- action: "upsert",
2268
- job: {
2360
+ else {
2361
+ cronService.addOrUpdate({
2269
2362
  id, userId, name, description: description ?? null, schedule, status,
2270
- skillId: params.skillId ?? null, assignedTo,
2363
+ skillId: params.skillId ?? null, assignedTo: null,
2271
2364
  lastRunAt: null, lastResult: null, lockedBy: null, lockedAt: null,
2272
- envId: envId ?? null,
2273
- },
2274
- });
2365
+ workspaceId: workspaceId ?? null,
2366
+ });
2367
+ }
2275
2368
  }
2276
2369
  return { id, name, schedule, status };
2277
2370
  });
@@ -2289,7 +2382,13 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2289
2382
  if (job.userId !== userId)
2290
2383
  throw new Error("Forbidden");
2291
2384
  await configRepo.deleteCronJob(id);
2292
- notifyCronService({ action: "delete", jobId: id });
2385
+ cronService?.cancel(id);
2386
+ // Auto-dismiss notifications for the deleted job
2387
+ if (notifRepo) {
2388
+ await notifRepo.dismissByTypeAndRelatedId("cron_success", id);
2389
+ await notifRepo.dismissByTypeAndRelatedId("cron_failure", id);
2390
+ await notifRepo.dismissByTypeAndRelatedId("cron_result", id); // legacy type
2391
+ }
2293
2392
  return { status: "deleted" };
2294
2393
  });
2295
2394
  methods.set("cron.setStatus", async (params, context) => {
@@ -2317,21 +2416,23 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2317
2416
  schedule: job.schedule,
2318
2417
  skillId: job.skillId ?? undefined,
2319
2418
  status,
2320
- envId: job.envId ?? null,
2419
+ workspaceId: job.workspaceId ?? null,
2321
2420
  });
2322
- // Notify cron service
2323
- notifyCronService({
2324
- action: status === "paused" ? "pause" : "upsert",
2325
- ...(status === "paused" ? { jobId: id } : {
2326
- job: {
2421
+ // Update scheduler
2422
+ if (cronService) {
2423
+ if (status === "paused") {
2424
+ cronService.cancel(id);
2425
+ }
2426
+ else {
2427
+ cronService.addOrUpdate({
2327
2428
  id, userId, name: job.name, description: job.description ?? null,
2328
2429
  schedule: job.schedule, status, skillId: job.skillId ?? null,
2329
- assignedTo: job.assignedTo ?? null,
2330
- lastRunAt: null, lastResult: null, lockedBy: null, lockedAt: null,
2331
- envId: job.envId ?? null,
2332
- },
2333
- }),
2334
- });
2430
+ assignedTo: null, lastRunAt: null, lastResult: null,
2431
+ lockedBy: null, lockedAt: null,
2432
+ workspaceId: job.workspaceId ?? null,
2433
+ });
2434
+ }
2435
+ }
2335
2436
  return { id, status };
2336
2437
  });
2337
2438
  methods.set("cron.rename", async (params, context) => {
@@ -2358,21 +2459,46 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2358
2459
  schedule: job.schedule,
2359
2460
  skillId: job.skillId ?? undefined,
2360
2461
  status: job.status,
2361
- envId: job.envId ?? null,
2462
+ workspaceId: job.workspaceId ?? null,
2362
2463
  });
2363
- // Notify cron service
2364
- notifyCronService({
2365
- action: "upsert",
2366
- job: {
2464
+ // Update scheduler (name change — reschedule with updated job data)
2465
+ if (cronService && job.status === "active") {
2466
+ cronService.addOrUpdate({
2367
2467
  id, userId, name: newName.trim(), description: job.description ?? null,
2368
- schedule: job.schedule, status: job.status, skillId: job.skillId ?? null,
2369
- assignedTo: job.assignedTo ?? null,
2468
+ schedule: job.schedule, status: job.status,
2469
+ skillId: job.skillId ?? null, assignedTo: null,
2370
2470
  lastRunAt: null, lastResult: null, lockedBy: null, lockedAt: null,
2371
- envId: job.envId ?? null,
2372
- },
2373
- });
2471
+ workspaceId: job.workspaceId ?? null,
2472
+ });
2473
+ }
2374
2474
  return { id, name: newName.trim() };
2375
2475
  });
2476
+ methods.set("cron.runs", async (params, context) => {
2477
+ const userId = requireAuth(context);
2478
+ if (!configRepo)
2479
+ throw new Error("Database not available");
2480
+ const jobId = params.jobId;
2481
+ if (!jobId)
2482
+ throw new Error("Missing required param: jobId");
2483
+ // Verify ownership
2484
+ const job = await configRepo.getCronJobById(jobId);
2485
+ if (!job)
2486
+ throw new Error("Job not found");
2487
+ if (job.userId !== userId)
2488
+ throw new Error("Forbidden");
2489
+ const limit = Math.min(Number(params.limit) || 20, 100);
2490
+ const runs = await configRepo.listCronJobRuns(jobId, limit);
2491
+ return {
2492
+ runs: runs.map((r) => ({
2493
+ id: r.id,
2494
+ status: r.status,
2495
+ resultText: r.resultText,
2496
+ error: r.error,
2497
+ durationMs: r.durationMs,
2498
+ createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt ? new Date(Number(r.createdAt) * 1000).toISOString() : null,
2499
+ })),
2500
+ };
2501
+ });
2376
2502
  // ─────────────────────────────────────────────────
2377
2503
  // Trigger Methods
2378
2504
  // ─────────────────────────────────────────────────
@@ -2820,6 +2946,9 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2820
2946
  const configJson = params.configJson;
2821
2947
  if (!name)
2822
2948
  throw new Error("Missing required param: name");
2949
+ if (type === "kubeconfig") {
2950
+ throw new Error("Kubeconfig credentials are now managed via Environments. Use userEnvConfig.set instead.");
2951
+ }
2823
2952
  if (!type || !CREDENTIAL_TYPES.includes(type)) {
2824
2953
  throw new Error(`Invalid credential type. Must be one of: ${CREDENTIAL_TYPES.join(", ")}`);
2825
2954
  }
@@ -2827,6 +2956,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2827
2956
  throw new Error("Missing required param: configJson");
2828
2957
  validateCredentialConfig(type, configJson);
2829
2958
  const id = await credRepo.create({ userId, name, type, description, configJson });
2959
+ pushCredentialsToUser(userId);
2830
2960
  return { id, name, type };
2831
2961
  });
2832
2962
  methods.set("credential.update", async (params, context) => {
@@ -2850,6 +2980,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2850
2980
  updates.configJson = configJson;
2851
2981
  }
2852
2982
  await credRepo.update(userId, id, updates);
2983
+ pushCredentialsToUser(userId);
2853
2984
  return { status: "updated" };
2854
2985
  });
2855
2986
  methods.set("credential.delete", async (params, context) => {
@@ -2863,9 +2994,253 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2863
2994
  if (!existing)
2864
2995
  throw new Error("Credential not found");
2865
2996
  await credRepo.delete(userId, id);
2997
+ pushCredentialsToUser(userId);
2866
2998
  return { status: "deleted" };
2867
2999
  });
2868
3000
  // ─────────────────────────────────────────────────
3001
+ // Environment Methods (admin-only)
3002
+ // ─────────────────────────────────────────────────
3003
+ methods.set("environment.list", async (_params, context) => {
3004
+ const userId = requireAuth(context);
3005
+ if (!envRepo)
3006
+ return { environments: [], isAdmin: false };
3007
+ const isAdmin = isAdminUser(context);
3008
+ // Check testOnly
3009
+ let isTestOnly = false;
3010
+ if (userRepo) {
3011
+ const dbUser = await userRepo.getById(userId);
3012
+ isTestOnly = dbUser?.testOnly ?? false;
3013
+ }
3014
+ const allEnvs = await envRepo.list();
3015
+ const visibleEnvs = isTestOnly ? allEnvs.filter((e) => e.isTest) : allEnvs;
3016
+ // Fetch user's kubeconfig status
3017
+ const userConfigs = userEnvConfigRepo ? await userEnvConfigRepo.listForUser(userId) : [];
3018
+ const configMap = new Map(userConfigs.map((c) => [c.envId, c]));
3019
+ return {
3020
+ isAdmin,
3021
+ environments: visibleEnvs.map((e) => ({
3022
+ id: e.id,
3023
+ name: e.name,
3024
+ isTest: e.isTest,
3025
+ apiServer: e.apiServer,
3026
+ allowedServers: e.allowedServers ? e.allowedServers.split(",").map((s) => s.trim()).filter(Boolean) : [],
3027
+ hasDefaultKubeconfig: !!e.defaultKubeconfig,
3028
+ hasUserKubeconfig: configMap.has(e.id),
3029
+ userConfigUpdatedAt: configMap.get(e.id)?.updatedAt?.toISOString?.() ?? configMap.get(e.id)?.updatedAt ?? null,
3030
+ createdBy: e.createdBy,
3031
+ createdAt: e.createdAt,
3032
+ updatedAt: e.updatedAt,
3033
+ })),
3034
+ };
3035
+ });
3036
+ methods.set("environment.create", async (params, context) => {
3037
+ const userId = requireAdmin(context);
3038
+ if (!envRepo)
3039
+ throw new Error("Database not available");
3040
+ const name = params.name;
3041
+ const isTest = params.isTest;
3042
+ const apiServer = params.apiServer;
3043
+ const rawAllowedServers = params.allowedServers;
3044
+ const allowedServers = Array.isArray(rawAllowedServers) ? rawAllowedServers.join(", ") : rawAllowedServers;
3045
+ const defaultKubeconfig = params.defaultKubeconfig;
3046
+ if (!name)
3047
+ throw new Error("Missing required param: name");
3048
+ if (!apiServer)
3049
+ throw new Error("Missing required param: apiServer");
3050
+ // Require explicit port in apiServer (e.g. https://host:6443)
3051
+ try {
3052
+ const u = new URL(apiServer.includes("://") ? apiServer : `https://${apiServer}`);
3053
+ if (!u.port)
3054
+ throw new Error("API Server must include an explicit port (e.g. https://host:6443)");
3055
+ }
3056
+ catch (e) {
3057
+ if (e instanceof Error && e.message.startsWith("API Server"))
3058
+ throw e;
3059
+ throw new Error("API Server must be a valid URL with an explicit port (e.g. https://host:6443)");
3060
+ }
3061
+ // defaultKubeconfig only accepted for test environments
3062
+ if (defaultKubeconfig && !isTest) {
3063
+ throw new Error("defaultKubeconfig can only be set for test environments");
3064
+ }
3065
+ const id = await envRepo.save({ name, isTest, apiServer, allowedServers: allowedServers || null, defaultKubeconfig: defaultKubeconfig ?? null }, userId);
3066
+ return { id, name };
3067
+ });
3068
+ methods.set("environment.update", async (params, context) => {
3069
+ requireAdmin(context);
3070
+ if (!envRepo)
3071
+ throw new Error("Database not available");
3072
+ const id = params.id;
3073
+ if (!id)
3074
+ throw new Error("Missing required param: id");
3075
+ const existing = await envRepo.getById(id);
3076
+ if (!existing)
3077
+ throw new Error("Environment not found");
3078
+ const name = params.name ?? existing.name;
3079
+ const apiServer = params.apiServer ?? existing.apiServer;
3080
+ const isTest = params.isTest !== undefined ? params.isTest : existing.isTest;
3081
+ const rawAllowed = params.allowedServers;
3082
+ const allowedServers = rawAllowed !== undefined
3083
+ ? (Array.isArray(rawAllowed) ? rawAllowed.join(", ") : rawAllowed)
3084
+ : existing.allowedServers;
3085
+ let defaultKubeconfig = params.defaultKubeconfig !== undefined ? params.defaultKubeconfig : existing.defaultKubeconfig;
3086
+ if (!apiServer?.trim()) {
3087
+ throw new Error("apiServer must be a non-empty string");
3088
+ }
3089
+ // Require explicit port in apiServer (e.g. https://host:6443)
3090
+ try {
3091
+ const u = new URL(apiServer.includes("://") ? apiServer : `https://${apiServer}`);
3092
+ if (!u.port)
3093
+ throw new Error("API Server must include an explicit port (e.g. https://host:6443)");
3094
+ }
3095
+ catch (e) {
3096
+ if (e instanceof Error && e.message.startsWith("API Server"))
3097
+ throw e;
3098
+ throw new Error("API Server must be a valid URL with an explicit port (e.g. https://host:6443)");
3099
+ }
3100
+ // If promoting from test to prod, auto-clear defaultKubeconfig
3101
+ if (existing.isTest && !isTest) {
3102
+ defaultKubeconfig = null;
3103
+ }
3104
+ // defaultKubeconfig only valid for test environments
3105
+ if (defaultKubeconfig && !isTest) {
3106
+ throw new Error("defaultKubeconfig can only be set for test environments");
3107
+ }
3108
+ const oldApiServer = existing.apiServer;
3109
+ await envRepo.save({ id, name, isTest, apiServer, allowedServers, defaultKubeconfig });
3110
+ // If apiServer changed, invalidate mismatched user kubeconfigs
3111
+ if (userEnvConfigRepo && apiServer !== oldApiServer) {
3112
+ const fullConfigs = await userEnvConfigRepo.listFullForEnv(id);
3113
+ const affectedUserIds = new Set();
3114
+ for (const cfg of fullConfigs) {
3115
+ try {
3116
+ const parsed = yaml.load(cfg.kubeconfig);
3117
+ const clusters = parsed?.clusters ?? [];
3118
+ const servers = clusters.map((c) => c.cluster?.server).filter(Boolean);
3119
+ const matches = servers.some((s) => apiServerHostMatch(s, apiServer));
3120
+ if (!matches) {
3121
+ await userEnvConfigRepo.remove(cfg.userId, id);
3122
+ affectedUserIds.add(cfg.userId);
3123
+ }
3124
+ }
3125
+ catch {
3126
+ // If kubeconfig can't be parsed, remove it as invalid
3127
+ await userEnvConfigRepo.remove(cfg.userId, id);
3128
+ affectedUserIds.add(cfg.userId);
3129
+ }
3130
+ }
3131
+ // Push updated credentials to affected users
3132
+ for (const uid of affectedUserIds) {
3133
+ pushCredentialsToUser(uid);
3134
+ }
3135
+ }
3136
+ return { status: "updated" };
3137
+ });
3138
+ methods.set("environment.delete", async (params, context) => {
3139
+ requireAdmin(context);
3140
+ if (!envRepo)
3141
+ throw new Error("Database not available");
3142
+ const id = params.id;
3143
+ if (!id)
3144
+ throw new Error("Missing required param: id");
3145
+ const existing = await envRepo.getById(id);
3146
+ if (!existing)
3147
+ throw new Error("Environment not found");
3148
+ // Collect affected users before cleanup
3149
+ const affectedUserIds = new Set();
3150
+ if (userEnvConfigRepo) {
3151
+ const envConfigs = await userEnvConfigRepo.listForEnv(id);
3152
+ for (const c of envConfigs)
3153
+ affectedUserIds.add(c.userId);
3154
+ await userEnvConfigRepo.removeAllForEnv(id);
3155
+ }
3156
+ await envRepo.delete(id);
3157
+ // Push updated credentials to all affected users
3158
+ for (const uid of affectedUserIds) {
3159
+ pushCredentialsToUser(uid);
3160
+ }
3161
+ return { status: "deleted" };
3162
+ });
3163
+ // ─────────────────────────────────────────────────
3164
+ // User Environment Config Methods (kubeconfig upload)
3165
+ // ─────────────────────────────────────────────────
3166
+ methods.set("userEnvConfig.list", async (_params, context) => {
3167
+ const userId = requireAuth(context);
3168
+ if (!envRepo || !userEnvConfigRepo)
3169
+ return { configs: [] };
3170
+ // Fetch all environments and user's configs
3171
+ const allEnvs = await envRepo.list();
3172
+ const userConfigs = await userEnvConfigRepo.listForUser(userId);
3173
+ // Check if user is testOnly
3174
+ let isTestOnly = false;
3175
+ if (userRepo) {
3176
+ const dbUser = await userRepo.getById(userId);
3177
+ isTestOnly = dbUser?.testOnly ?? false;
3178
+ }
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]));
3182
+ 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,
3190
+ })),
3191
+ };
3192
+ });
3193
+ methods.set("userEnvConfig.set", async (params, context) => {
3194
+ const userId = requireAuth(context);
3195
+ if (!envRepo || !userEnvConfigRepo)
3196
+ throw new Error("Database not available");
3197
+ const envId = params.envId;
3198
+ const kubeconfig = params.kubeconfig;
3199
+ if (!envId)
3200
+ throw new Error("Missing required param: envId");
3201
+ if (!kubeconfig)
3202
+ 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");
3207
+ // testOnly user check
3208
+ if (userRepo) {
3209
+ const dbUser = await userRepo.getById(userId);
3210
+ if (dbUser?.testOnly && !env.isTest) {
3211
+ throw new Error("Test-only users cannot configure production environments");
3212
+ }
3213
+ }
3214
+ // Parse and validate kubeconfig YAML
3215
+ let parsed;
3216
+ try {
3217
+ parsed = yaml.load(kubeconfig);
3218
+ }
3219
+ catch {
3220
+ throw new Error("Invalid kubeconfig: YAML parse error");
3221
+ }
3222
+ // 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}"`);
3227
+ }
3228
+ await userEnvConfigRepo.set(userId, envId, kubeconfig);
3229
+ pushCredentialsToUser(userId);
3230
+ return { status: "saved" };
3231
+ });
3232
+ methods.set("userEnvConfig.remove", async (params, context) => {
3233
+ const userId = requireAuth(context);
3234
+ if (!userEnvConfigRepo)
3235
+ 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);
3240
+ pushCredentialsToUser(userId);
3241
+ return { status: "removed" };
3242
+ });
3243
+ // ─────────────────────────────────────────────────
2869
3244
  // Workspace Methods
2870
3245
  // ─────────────────────────────────────────────────
2871
3246
  methods.set("workspace.list", async (_params, context) => {
@@ -2875,7 +3250,14 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2875
3250
  let list = await workspaceRepo.list(userId);
2876
3251
  // Auto-create default workspace if none exists
2877
3252
  if (list.length === 0) {
2878
- await workspaceRepo.getOrCreateDefault(userId);
3253
+ // testOnly users get a test-type default workspace
3254
+ let defaultEnvType;
3255
+ if (userRepo) {
3256
+ const dbUser = await userRepo.getById(userId);
3257
+ if (dbUser?.testOnly)
3258
+ defaultEnvType = "test";
3259
+ }
3260
+ await workspaceRepo.getOrCreateDefault(userId, defaultEnvType);
2879
3261
  list = await workspaceRepo.list(userId);
2880
3262
  }
2881
3263
  return { workspaces: list };
@@ -2887,8 +3269,18 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2887
3269
  const name = params.name;
2888
3270
  if (!name)
2889
3271
  throw new Error("Missing required param: name");
3272
+ // Determine envType — testOnly users forced to "test"
3273
+ let envType = params.envType ?? "prod";
3274
+ if (envType !== "prod" && envType !== "test") {
3275
+ throw new Error("envType must be 'prod' or 'test'");
3276
+ }
3277
+ if (userRepo) {
3278
+ const dbUser = await userRepo.getById(userId);
3279
+ if (dbUser?.testOnly)
3280
+ envType = "test";
3281
+ }
2890
3282
  const config = params.config;
2891
- const ws = await workspaceRepo.create(userId, name, config);
3283
+ const ws = await workspaceRepo.create(userId, name, config, envType);
2892
3284
  // Build workspace skills directory
2893
3285
  await syncWorkspaceSkills(userId, ws.id, ws.isDefault, [], []);
2894
3286
  return { workspace: ws };
@@ -2909,6 +3301,31 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2909
3301
  updates.name = params.name;
2910
3302
  if (params.config !== undefined)
2911
3303
  updates.configJson = params.config;
3304
+ if (params.envType !== undefined) {
3305
+ const envType = params.envType;
3306
+ if (envType !== "prod" && envType !== "test") {
3307
+ throw new Error("envType must be 'prod' or 'test'");
3308
+ }
3309
+ // testOnly users cannot set envType to "prod"
3310
+ if (userRepo) {
3311
+ const dbUser = await userRepo.getById(userId);
3312
+ if (dbUser?.testOnly && envType === "prod") {
3313
+ throw new Error("Test-only users cannot create production workspaces");
3314
+ }
3315
+ }
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);
3322
+ 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.`);
3324
+ }
3325
+ }
3326
+ }
3327
+ updates.envType = params.envType;
3328
+ }
2912
3329
  await workspaceRepo.update(id, updates);
2913
3330
  return { status: "updated" };
2914
3331
  });
@@ -2940,16 +3357,25 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2940
3357
  const ws = await workspaceRepo.getById(id);
2941
3358
  if (!ws || ws.userId !== userId)
2942
3359
  throw new Error("Workspace not found");
2943
- const [wsSkills, wsTools, wsCreds] = await Promise.all([
3360
+ const [wsSkills, wsTools, wsCreds, wsEnvIds] = await Promise.all([
2944
3361
  workspaceRepo.getSkills(id),
2945
3362
  workspaceRepo.getTools(id),
2946
3363
  workspaceRepo.getCredentials(id),
3364
+ workspaceRepo.getEnvironments(id),
2947
3365
  ]);
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 }));
3371
+ }
2948
3372
  return {
2949
3373
  workspace: ws,
2950
3374
  skills: wsSkills,
2951
3375
  tools: wsTools,
2952
3376
  credentials: wsCreds,
3377
+ environments: wsEnvIds,
3378
+ environmentDetails: envDetails,
2953
3379
  };
2954
3380
  });
2955
3381
  methods.set("workspace.setSkills", async (params, context) => {
@@ -3016,6 +3442,50 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3016
3442
  }
3017
3443
  }
3018
3444
  await workspaceRepo.setCredentials(workspaceId, credentialIds);
3445
+ // Push updated credentials to running AgentBox for this workspace
3446
+ pushCredentialsToUser(userId);
3447
+ return { status: "updated" };
3448
+ });
3449
+ methods.set("workspace.setEnvironments", async (params, context) => {
3450
+ const userId = requireAuth(context);
3451
+ if (!workspaceRepo)
3452
+ throw new Error("Database not available");
3453
+ const workspaceId = params.workspaceId;
3454
+ const envIds = params.envIds;
3455
+ if (!workspaceId || !Array.isArray(envIds))
3456
+ throw new Error("Missing required params");
3457
+ const ws = await workspaceRepo.getById(workspaceId);
3458
+ if (!ws || ws.userId !== userId)
3459
+ 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");
3468
+ }
3469
+ if (ws.envType === "test") {
3470
+ const nonTest = envs.filter((e) => !e.isTest);
3471
+ if (nonTest.length > 0) {
3472
+ throw new Error("Test workspaces can only bind test environments");
3473
+ }
3474
+ }
3475
+ // testOnly user check
3476
+ if (userRepo) {
3477
+ const dbUser = await userRepo.getById(userId);
3478
+ if (dbUser?.testOnly) {
3479
+ const nonTest = envs.filter((e) => !e.isTest);
3480
+ if (nonTest.length > 0) {
3481
+ throw new Error("Test-only users cannot bind production environments");
3482
+ }
3483
+ }
3484
+ }
3485
+ }
3486
+ await workspaceRepo.setEnvironments(workspaceId, envIds);
3487
+ // Push updated credentials to running AgentBox for this workspace
3488
+ pushCredentialsToUser(userId);
3019
3489
  return { status: "updated" };
3020
3490
  });
3021
3491
  methods.set("workspace.availableTools", async (_params, context) => {
@@ -3041,8 +3511,12 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3041
3511
  });
3042
3512
  /**
3043
3513
  * Build credential payload for a workspace.
3044
- * Default workspace: returns ALL user credentials.
3045
- * Custom workspace: returns only linked credentials.
3514
+ *
3515
+ * Kubeconfigs: sourced from environment-bound userEnvConfigs (NOT credentials table).
3516
+ * Other credentials: from credentials table, filtered by workspace envType.
3517
+ * - prod workspace: all workspace-linked credentials
3518
+ * - test workspace: NO non-kubeconfig credentials (SSH, API tokens hidden)
3519
+ *
3046
3520
  * Returns data only — does NOT write to disk. Agentbox materializes files locally.
3047
3521
  */
3048
3522
  async function buildCredentialPayload(userId, workspaceId, isDefault) {
@@ -3051,16 +3525,83 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3051
3525
  if (!fs.existsSync(agentDataDir)) {
3052
3526
  fs.mkdirSync(agentDataDir, { recursive: true });
3053
3527
  }
3054
- if (!credRepo)
3055
- return { manifest: [], files: [] };
3056
- // Determine which credentials to provision
3528
+ const manifest = [];
3529
+ const files = [];
3530
+ // ── Step 1: Determine workspace envType ──
3531
+ let envType = "prod";
3532
+ if (workspaceRepo) {
3533
+ const ws = await workspaceRepo.getById(workspaceId);
3534
+ if (ws)
3535
+ envType = ws.envType ?? "prod";
3536
+ }
3537
+ // ── Step 2: Kubeconfigs from environments ──
3538
+ if (envRepo && userEnvConfigRepo) {
3539
+ // Default workspace: all environments; non-default: only workspace-bound
3540
+ let envs;
3541
+ if (isDefault) {
3542
+ envs = await envRepo.list();
3543
+ }
3544
+ else if (workspaceRepo) {
3545
+ const boundEnvIds = await workspaceRepo.getEnvironments(workspaceId);
3546
+ envs = boundEnvIds.length > 0 ? await envRepo.listByIds(boundEnvIds) : [];
3547
+ }
3548
+ else {
3549
+ envs = [];
3550
+ }
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)
3555
+ continue;
3556
+ // Get user's kubeconfig for this environment
3557
+ const userConfig = await userEnvConfigRepo.get(userId, env.id);
3558
+ let kubeconfigContent = userConfig?.kubeconfig ?? null;
3559
+ // Fallback to defaultKubeconfig for test environments
3560
+ if (!kubeconfigContent && env.isTest && env.defaultKubeconfig) {
3561
+ kubeconfigContent = env.defaultKubeconfig;
3562
+ }
3563
+ if (kubeconfigContent) {
3564
+ const safeName = env.name.replace(/[^a-zA-Z0-9_-]/g, "_");
3565
+ const filename = `${safeName}.kubeconfig`;
3566
+ files.push({ name: filename, content: kubeconfigContent });
3567
+ const fileNames = [filename];
3568
+ let metadata;
3569
+ try {
3570
+ const kc = yaml.load(kubeconfigContent);
3571
+ const clusters = kc?.clusters ?? [];
3572
+ const contexts = kc?.contexts ?? [];
3573
+ metadata = {
3574
+ clusters: clusters.map((c) => ({ name: c.name, server: c.cluster?.server })),
3575
+ contexts: contexts.map((c) => ({ name: c.name, cluster: c.context?.cluster, namespace: c.context?.namespace })),
3576
+ currentContext: kc?.["current-context"],
3577
+ };
3578
+ }
3579
+ catch {
3580
+ // ignore parse errors
3581
+ }
3582
+ manifest.push({
3583
+ name: env.name,
3584
+ type: "kubeconfig",
3585
+ description: `Kubeconfig for environment: ${env.name}`,
3586
+ files: fileNames,
3587
+ ...(metadata ? { metadata } : {}),
3588
+ });
3589
+ }
3590
+ }
3591
+ }
3592
+ }
3593
+ // ── Step 3: Non-kubeconfig credentials from credentials table ──
3594
+ // Test workspaces get NO non-kubeconfig credentials
3595
+ if (envType === "test" || !credRepo) {
3596
+ return { manifest, files };
3597
+ }
3057
3598
  let creds;
3058
3599
  if (isDefault) {
3059
3600
  creds = await credRepo.listForUser(userId);
3060
3601
  }
3061
3602
  else {
3062
3603
  if (!workspaceRepo)
3063
- return { manifest: [], files: [] };
3604
+ return { manifest, files };
3064
3605
  const linkedIds = await workspaceRepo.getCredentials(workspaceId);
3065
3606
  if (linkedIds.length === 0) {
3066
3607
  creds = [];
@@ -3071,43 +3612,15 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3071
3612
  }
3072
3613
  // IdentityFile path used inside agentbox (resolves to .siclaw/credentials/)
3073
3614
  const credsDirInBox = path.resolve(process.cwd(), ".siclaw/credentials");
3074
- const manifest = [];
3075
- const files = [];
3076
3615
  for (const cred of creds) {
3616
+ // Skip kubeconfig type credentials (now handled via environments)
3617
+ if (cred.type === "kubeconfig")
3618
+ continue;
3077
3619
  const config = (cred.configJson ?? {});
3078
3620
  const safeName = cred.name.replace(/[^a-zA-Z0-9_-]/g, "_");
3079
3621
  const fileNames = [];
3080
3622
  let metadata;
3081
3623
  switch (cred.type) {
3082
- case "kubeconfig": {
3083
- const content = config.content;
3084
- if (content) {
3085
- const filename = `${safeName}.kubeconfig`;
3086
- files.push({ name: filename, content });
3087
- fileNames.push(filename);
3088
- try {
3089
- const kc = yaml.load(content);
3090
- const clusters = kc?.clusters ?? [];
3091
- const contexts = kc?.contexts ?? [];
3092
- metadata = {
3093
- clusters: clusters.map((c) => ({
3094
- name: c.name,
3095
- server: c.cluster?.server,
3096
- })),
3097
- contexts: contexts.map((c) => ({
3098
- name: c.name,
3099
- cluster: c.context?.cluster,
3100
- namespace: c.context?.namespace,
3101
- })),
3102
- currentContext: kc?.["current-context"],
3103
- };
3104
- }
3105
- catch {
3106
- // ignore parse errors
3107
- }
3108
- }
3109
- break;
3110
- }
3111
3624
  case "ssh_key": {
3112
3625
  const privateKey = config.privateKey;
3113
3626
  if (privateKey) {
@@ -3237,11 +3750,17 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3237
3750
  const sessions = await chatRepo.listSessions(userId, 1);
3238
3751
  sessionCount = sessions.length;
3239
3752
  }
3240
- // PROFILE.md exists?
3753
+ // PROFILE.md exists with meaningful (non-skeleton) content?
3754
+ // A skeleton PROFILE.md (all TBD) doesn't count — the user still needs onboarding.
3241
3755
  const userDataDir = process.env.SICLAW_USER_DATA_DIR || ".siclaw/user-data";
3242
3756
  const profilePath = path.resolve(userDataDir, "memory", "PROFILE.md");
3243
- const hasProfile = fs.existsSync(profilePath);
3244
- // Credentials by type count
3757
+ let hasProfile = false;
3758
+ if (fs.existsSync(profilePath)) {
3759
+ const content = fs.readFileSync(profilePath, "utf-8");
3760
+ // Profile is "real" if Name field has been filled (not TBD)
3761
+ hasProfile = /\*\*Name\*\*:\s*(?!TBD).+/i.test(content);
3762
+ }
3763
+ // Credentials by type count (SSH/API + kubeconfigs)
3245
3764
  const credentials = {};
3246
3765
  if (credRepo) {
3247
3766
  const creds = await credRepo.listForUser(userId);
@@ -3249,6 +3768,20 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3249
3768
  credentials[c.type] = (credentials[c.type] || 0) + 1;
3250
3769
  }
3251
3770
  }
3771
+ if (envRepo && userEnvConfigRepo) {
3772
+ const allEnvs = await envRepo.list();
3773
+ let kubeconfigCount = 0;
3774
+ for (const env of allEnvs) {
3775
+ // 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);
3777
+ if (userConfig?.kubeconfig || (env.isTest && env.defaultKubeconfig)) {
3778
+ kubeconfigCount++;
3779
+ }
3780
+ }
3781
+ if (kubeconfigCount > 0) {
3782
+ credentials["kubeconfig"] = kubeconfigCount;
3783
+ }
3784
+ }
3252
3785
  return { hasModels, hasProfile, sessionCount, credentials };
3253
3786
  });
3254
3787
  // ─────────────────────────────────────────────────
@@ -3269,7 +3802,8 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3269
3802
  const values = params.values;
3270
3803
  const ALLOWED_SECTIONS = {
3271
3804
  sso: ["sso.enabled", "sso.issuer", "sso.clientId", "sso.clientSecret", "sso.redirectUri"],
3272
- system: ["system.baseUrl", "system.platformUrl", "system.agentboxImage"],
3805
+ system: ["system.grafanaUrl"],
3806
+ metrics: ["metrics.port", "metrics.token", "metrics.includeUserId"],
3273
3807
  };
3274
3808
  const allowedKeys = ALLOWED_SECTIONS[section];
3275
3809
  if (!allowedKeys)
@@ -3283,29 +3817,181 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3283
3817
  }
3284
3818
  }
3285
3819
  await sysConfigRepo.setMany(entries);
3286
- // Apply agentbox image change at runtime
3287
- if (section === "system" && entries["system.agentboxImage"]) {
3288
- agentBoxManager.setSpawnerImage(entries["system.agentboxImage"]);
3289
- }
3290
3820
  return { ok: true };
3291
3821
  });
3292
- /** Build a skill bundle for a given user and environment (used by mTLS bundle API) */
3822
+ /** Build a skill bundle for a given user and environment (used by mTLS bundle API).
3823
+ * "test" maps to "dev" behavior (working copies of personal skills). */
3293
3824
  async function getSkillBundle(userId, env) {
3294
3825
  if (!skillRepo || !skillContentRepo)
3295
3826
  throw new Error("Database not available");
3296
3827
  const disabled = new Set(await skillRepo.listDisabledSkills(userId));
3297
- return buildSkillBundle(userId, env, skillWriter, skillRepo, skillContentRepo, disabled);
3828
+ // Map "test" → "dev" for skill bundle purposes (test = dev-like skill access)
3829
+ const bundleEnv = env === "test" ? "dev" : env;
3830
+ return buildSkillBundle(userId, bundleEnv, skillWriter, skillRepo, skillContentRepo, disabled);
3298
3831
  }
3299
- /** Abort all SSE streams associated with a specific WebSocket connection */
3832
+ /** Detach WebSocket from SSE streams SSE continues so DB persistence and
3833
+ * dpProgressSnapshots keep updating. User reconnect resumes live events. */
3300
3834
  function cleanupForWs(ws) {
3301
3835
  for (const [key, stream] of activeStreams.entries()) {
3302
3836
  if (stream.ws === ws) {
3303
- console.log(`[rpc] Cleaning up SSE stream ${key} (WS closed)`);
3304
- stream.abort();
3305
- activeStreams.delete(key);
3837
+ console.log(`[rpc] WS detached from SSE stream ${key} SSE continues`);
3838
+ stream.ws = undefined;
3306
3839
  }
3307
3840
  }
3308
3841
  }
3842
+ // ── Monitoring Dashboard ──
3843
+ methods.set("metrics.timeseries", async (params, context) => {
3844
+ requireAuth(context);
3845
+ if (!metricsAggregator)
3846
+ return { buckets: [], snapshot: { activeSessions: 0, wsConnections: 0 }, topTools: [], topSkills: [] };
3847
+ const range = params.range || "1h";
3848
+ if (range !== "1h" && range !== "6h" && range !== "24h") {
3849
+ throw new Error("Invalid range: must be 1h, 6h, or 24h");
3850
+ }
3851
+ const buckets = metricsAggregator.query(range).map((b) => ({
3852
+ timestamp: b.timestamp,
3853
+ tokensInput: b.tokensInput,
3854
+ tokensOutput: b.tokensOutput,
3855
+ tokensCacheRead: b.tokensCacheRead,
3856
+ tokensCacheWrite: b.tokensCacheWrite,
3857
+ promptCount: b.promptCount,
3858
+ promptErrors: b.promptErrors,
3859
+ promptDurationAvg: b.promptCount + b.promptErrors > 0
3860
+ ? b.promptDurationSum / (b.promptCount + b.promptErrors)
3861
+ : 0,
3862
+ promptDurationMax: b.promptDurationMax,
3863
+ activeSessions: b.activeSessions,
3864
+ wsConnections: b.wsConnections,
3865
+ toolCalls: b.toolCalls,
3866
+ toolErrors: b.toolErrors,
3867
+ skillSuccesses: b.skillSuccesses,
3868
+ skillErrors: b.skillErrors,
3869
+ }));
3870
+ return {
3871
+ buckets,
3872
+ snapshot: metricsAggregator.snapshot(),
3873
+ topTools: metricsAggregator.topTools(10),
3874
+ topSkills: metricsAggregator.topSkills(10),
3875
+ };
3876
+ });
3877
+ methods.set("metrics.summary", async (params, context) => {
3878
+ requireAuth(context);
3879
+ if (!db)
3880
+ return { totalTokens: 0, totalPrompts: 0, totalSessions: 0, tokenBreakdown: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, byModel: [] };
3881
+ const period = params.period || "today";
3882
+ const now = new Date();
3883
+ let cutoffMs;
3884
+ if (period === "7d") {
3885
+ cutoffMs = now.getTime() - 7 * 86_400_000;
3886
+ }
3887
+ else if (period === "30d") {
3888
+ cutoffMs = now.getTime() - 30 * 86_400_000;
3889
+ }
3890
+ else {
3891
+ // "today" — UTC start of day
3892
+ cutoffMs = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).getTime();
3893
+ }
3894
+ const rows = await db.select({
3895
+ provider: sessionStats.provider,
3896
+ model: sessionStats.model,
3897
+ session_count: count(),
3898
+ total_input: sum(sessionStats.inputTokens),
3899
+ total_output: sum(sessionStats.outputTokens),
3900
+ total_cache_read: sum(sessionStats.cacheReadTokens),
3901
+ total_cache_write: sum(sessionStats.cacheWriteTokens),
3902
+ total_prompts: sum(sessionStats.promptCount),
3903
+ })
3904
+ .from(sessionStats)
3905
+ .where(gte(sessionStats.createdAt, cutoffMs))
3906
+ .groupBy(sessionStats.provider, sessionStats.model)
3907
+ .orderBy(sql `(SUM(${sessionStats.inputTokens}) + SUM(${sessionStats.outputTokens})) DESC`);
3908
+ let totalTokens = 0;
3909
+ let totalPrompts = 0;
3910
+ let totalSessions = 0;
3911
+ const tokenBreakdown = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
3912
+ const byModel = [];
3913
+ for (const row of rows) {
3914
+ const input = Number(row.total_input) || 0;
3915
+ const output = Number(row.total_output) || 0;
3916
+ const cacheRead = Number(row.total_cache_read) || 0;
3917
+ const cacheWrite = Number(row.total_cache_write) || 0;
3918
+ const tokens = input + output;
3919
+ const prompts = Number(row.total_prompts) || 0;
3920
+ const sessions = Number(row.session_count) || 0;
3921
+ totalTokens += tokens;
3922
+ totalPrompts += prompts;
3923
+ totalSessions += sessions;
3924
+ tokenBreakdown.input += input;
3925
+ tokenBreakdown.output += output;
3926
+ tokenBreakdown.cacheRead += cacheRead;
3927
+ tokenBreakdown.cacheWrite += cacheWrite;
3928
+ byModel.push({
3929
+ provider: row.provider || "unknown",
3930
+ model: row.model || "unknown",
3931
+ tokens,
3932
+ sessions,
3933
+ percentage: 0, // filled below
3934
+ });
3935
+ }
3936
+ // Calculate percentages
3937
+ for (const entry of byModel) {
3938
+ entry.percentage = totalTokens > 0 ? Math.round((entry.tokens / totalTokens) * 100) : 0;
3939
+ }
3940
+ return { totalTokens, totalPrompts, totalSessions, tokenBreakdown, byModel };
3941
+ });
3942
+ // ── Audit ──────────────────────────────────────────────────────────
3943
+ methods.set("audit.list", async (params, context) => {
3944
+ const userId = requireAuth(context);
3945
+ if (!chatRepo)
3946
+ throw new Error("Database not available");
3947
+ const p = params;
3948
+ const queryUserId = isAdminUser(context) ? (p.userId || undefined) : userId;
3949
+ const limit = Math.min(p.limit ?? 50, 200);
3950
+ const validOutcomes = ["success", "error", "blocked"];
3951
+ const outcome = p.outcome && validOutcomes.includes(p.outcome) ? p.outcome : undefined;
3952
+ const rows = await chatRepo.queryAuditLogs({
3953
+ userId: queryUserId,
3954
+ userName: isAdminUser(context) ? p.userName : undefined,
3955
+ toolName: p.toolName,
3956
+ outcome,
3957
+ startDate: p.startDate ? Math.floor(new Date(p.startDate).getTime() / 1000) : undefined,
3958
+ endDate: p.endDate ? Math.floor(new Date(p.endDate).getTime() / 1000) : undefined,
3959
+ cursorTs: p.cursorTs,
3960
+ cursorId: p.cursorId,
3961
+ limit,
3962
+ });
3963
+ const hasMore = rows.length > limit;
3964
+ const logs = hasMore ? rows.slice(0, limit) : rows;
3965
+ return { logs, hasMore };
3966
+ });
3967
+ methods.set("audit.detail", async (params, context) => {
3968
+ const userId = requireAuth(context);
3969
+ if (!chatRepo)
3970
+ throw new Error("Database not available");
3971
+ const { messageId } = params;
3972
+ if (!messageId)
3973
+ throw new Error("messageId is required");
3974
+ const msg = await chatRepo.getMessageById(messageId);
3975
+ if (!msg || msg.role !== "tool")
3976
+ throw new Error("Message not found");
3977
+ // Ownership check via session
3978
+ const session = await chatRepo.getSession(msg.sessionId);
3979
+ if (!session)
3980
+ throw new Error("Session not found");
3981
+ if (!isAdminUser(context) && session.userId !== userId) {
3982
+ throw new Error("Forbidden: not your message");
3983
+ }
3984
+ return {
3985
+ id: msg.id,
3986
+ userId: msg.userId,
3987
+ content: msg.content,
3988
+ toolName: msg.toolName,
3989
+ toolInput: msg.toolInput,
3990
+ outcome: msg.outcome,
3991
+ durationMs: msg.durationMs,
3992
+ timestamp: msg.timestamp,
3993
+ };
3994
+ });
3309
3995
  return { methods, buildCredentialPayload, getSkillBundle, cleanupForWs };
3310
3996
  }
3311
3997
  //# sourceMappingURL=rpc-methods.js.map