siclaw 0.1.0 → 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 (270) hide show
  1. package/README.md +75 -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.d.ts +1 -1
  90. package/dist/gateway/db/repositories/model-config-repo.js +26 -12
  91. package/dist/gateway/db/repositories/model-config-repo.js.map +1 -1
  92. package/dist/gateway/db/repositories/skill-repo.d.ts +0 -5
  93. package/dist/gateway/db/repositories/skill-review-repo.d.ts +1 -0
  94. package/dist/gateway/db/repositories/skill-review-repo.js +4 -1
  95. package/dist/gateway/db/repositories/skill-review-repo.js.map +1 -1
  96. package/dist/gateway/db/repositories/skill-version-repo.js +0 -1
  97. package/dist/gateway/db/repositories/skill-version-repo.js.map +1 -1
  98. package/dist/gateway/db/repositories/system-config-repo.d.ts +1 -1
  99. package/dist/gateway/db/repositories/system-config-repo.js +2 -1
  100. package/dist/gateway/db/repositories/system-config-repo.js.map +1 -1
  101. package/dist/gateway/db/repositories/user-env-config-repo.d.ts +13 -0
  102. package/dist/gateway/db/repositories/user-env-config-repo.js +11 -0
  103. package/dist/gateway/db/repositories/user-env-config-repo.js.map +1 -1
  104. package/dist/gateway/db/repositories/workspace-repo.d.ts +3 -2
  105. package/dist/gateway/db/repositories/workspace-repo.js +6 -2
  106. package/dist/gateway/db/repositories/workspace-repo.js.map +1 -1
  107. package/dist/gateway/db/schema-mysql.d.ts +473 -51
  108. package/dist/gateway/db/schema-mysql.js +35 -4
  109. package/dist/gateway/db/schema-mysql.js.map +1 -1
  110. package/dist/gateway/db/schema-sqlite.d.ts +522 -57
  111. package/dist/gateway/db/schema-sqlite.js +38 -6
  112. package/dist/gateway/db/schema-sqlite.js.map +1 -1
  113. package/dist/gateway/db/schema.d.ts +471 -51
  114. package/dist/gateway/db/schema.js +1 -1
  115. package/dist/gateway/db/schema.js.map +1 -1
  116. package/dist/gateway/metrics-aggregator.d.ts +65 -0
  117. package/dist/gateway/metrics-aggregator.js +244 -0
  118. package/dist/gateway/metrics-aggregator.js.map +1 -0
  119. package/dist/gateway/plugins/channel-bridge.d.ts +4 -1
  120. package/dist/gateway/plugins/channel-bridge.js +78 -86
  121. package/dist/gateway/plugins/channel-bridge.js.map +1 -1
  122. package/dist/gateway/rpc-methods.d.ts +4 -2
  123. package/dist/gateway/rpc-methods.js +962 -163
  124. package/dist/gateway/rpc-methods.js.map +1 -1
  125. package/dist/gateway/security/cert-manager.d.ts +2 -2
  126. package/dist/gateway/security/cert-manager.js +4 -2
  127. package/dist/gateway/security/cert-manager.js.map +1 -1
  128. package/dist/gateway/server.d.ts +4 -8
  129. package/dist/gateway/server.js +297 -261
  130. package/dist/gateway/server.js.map +1 -1
  131. package/dist/gateway/skills/file-writer.js +17 -11
  132. package/dist/gateway/skills/file-writer.js.map +1 -1
  133. package/dist/gateway/skills/script-evaluator.js +12 -9
  134. package/dist/gateway/skills/script-evaluator.js.map +1 -1
  135. package/dist/gateway/web/dist/assets/index-0p17ZeTP.js +740 -0
  136. package/dist/gateway/web/dist/assets/index-9eP6nPUq.js +741 -0
  137. package/dist/gateway/web/dist/assets/index-9eP6nPUq.js.map +1 -0
  138. package/dist/gateway/web/dist/assets/index-CAmSY91d.js +675 -0
  139. package/dist/gateway/web/dist/assets/index-DMFEh8Pp.css +1 -0
  140. package/dist/gateway/web/dist/assets/index-DyowBCEj.css +1 -0
  141. package/dist/gateway/web/dist/assets/index-PDK5JJDO.css +1 -0
  142. package/dist/gateway/web/dist/index.html +2 -2
  143. package/dist/gateway-main.js +27 -10
  144. package/dist/gateway-main.js.map +1 -1
  145. package/dist/memory/embeddings.js +5 -4
  146. package/dist/memory/embeddings.js.map +1 -1
  147. package/dist/memory/indexer.d.ts +23 -3
  148. package/dist/memory/indexer.js +235 -23
  149. package/dist/memory/indexer.js.map +1 -1
  150. package/dist/memory/schema.js +15 -1
  151. package/dist/memory/schema.js.map +1 -1
  152. package/dist/memory/types.d.ts +18 -0
  153. package/dist/memory/types.js +6 -1
  154. package/dist/memory/types.js.map +1 -1
  155. package/dist/shared/detect-language.d.ts +12 -0
  156. package/dist/shared/detect-language.js +78 -0
  157. package/dist/shared/detect-language.js.map +1 -0
  158. package/dist/shared/diagnostic-events.d.ts +70 -0
  159. package/dist/shared/diagnostic-events.js +38 -0
  160. package/dist/shared/diagnostic-events.js.map +1 -0
  161. package/dist/shared/local-collector.d.ts +56 -0
  162. package/dist/shared/local-collector.js +284 -0
  163. package/dist/shared/local-collector.js.map +1 -0
  164. package/dist/shared/metrics-types.d.ts +64 -0
  165. package/dist/shared/metrics-types.js +25 -0
  166. package/dist/shared/metrics-types.js.map +1 -0
  167. package/dist/shared/metrics.d.ts +19 -0
  168. package/dist/shared/metrics.js +185 -0
  169. package/dist/shared/metrics.js.map +1 -0
  170. package/dist/shared/path-utils.d.ts +15 -0
  171. package/dist/shared/path-utils.js +23 -0
  172. package/dist/shared/path-utils.js.map +1 -0
  173. package/dist/shared/retry.d.ts +35 -0
  174. package/dist/shared/retry.js +61 -0
  175. package/dist/shared/retry.js.map +1 -0
  176. package/dist/tools/command-sets.d.ts +18 -2
  177. package/dist/tools/command-sets.js +207 -32
  178. package/dist/tools/command-sets.js.map +1 -1
  179. package/dist/tools/command-validator.d.ts +56 -0
  180. package/dist/tools/command-validator.js +357 -0
  181. package/dist/tools/command-validator.js.map +1 -0
  182. package/dist/tools/create-skill.js +26 -1
  183. package/dist/tools/create-skill.js.map +1 -1
  184. package/dist/tools/credential-list.js +1 -23
  185. package/dist/tools/credential-list.js.map +1 -1
  186. package/dist/tools/credential-manager.d.ts +98 -0
  187. package/dist/tools/credential-manager.js +313 -0
  188. package/dist/tools/credential-manager.js.map +1 -0
  189. package/dist/tools/deep-search/engine.js +184 -127
  190. package/dist/tools/deep-search/engine.js.map +1 -1
  191. package/dist/tools/deep-search/prompts.d.ts +10 -2
  192. package/dist/tools/deep-search/prompts.js +37 -36
  193. package/dist/tools/deep-search/prompts.js.map +1 -1
  194. package/dist/tools/deep-search/schemas.d.ts +87 -0
  195. package/dist/tools/deep-search/schemas.js +85 -0
  196. package/dist/tools/deep-search/schemas.js.map +1 -0
  197. package/dist/tools/deep-search/sub-agent.d.ts +21 -0
  198. package/dist/tools/deep-search/sub-agent.js +153 -4
  199. package/dist/tools/deep-search/sub-agent.js.map +1 -1
  200. package/dist/tools/deep-search/tool.js +1 -0
  201. package/dist/tools/deep-search/tool.js.map +1 -1
  202. package/dist/tools/deep-search/types.d.ts +2 -0
  203. package/dist/tools/deep-search/types.js.map +1 -1
  204. package/dist/tools/dp-tools.js +29 -5
  205. package/dist/tools/dp-tools.js.map +1 -1
  206. package/dist/tools/exec-utils.d.ts +85 -0
  207. package/dist/tools/exec-utils.js +294 -0
  208. package/dist/tools/exec-utils.js.map +1 -0
  209. package/dist/tools/fork-skill.js +14 -2
  210. package/dist/tools/fork-skill.js.map +1 -1
  211. package/dist/tools/investigation-feedback.d.ts +3 -0
  212. package/dist/tools/investigation-feedback.js +71 -0
  213. package/dist/tools/investigation-feedback.js.map +1 -0
  214. package/dist/tools/manage-schedule.js +16 -6
  215. package/dist/tools/manage-schedule.js.map +1 -1
  216. package/dist/tools/netns-script.js +27 -281
  217. package/dist/tools/netns-script.js.map +1 -1
  218. package/dist/tools/node-exec.d.ts +2 -14
  219. package/dist/tools/node-exec.js +18 -225
  220. package/dist/tools/node-exec.js.map +1 -1
  221. package/dist/tools/node-script.js +14 -168
  222. package/dist/tools/node-script.js.map +1 -1
  223. package/dist/tools/pod-exec.d.ts +1 -1
  224. package/dist/tools/pod-exec.js +10 -26
  225. package/dist/tools/pod-exec.js.map +1 -1
  226. package/dist/tools/pod-nsenter-exec.js +21 -225
  227. package/dist/tools/pod-nsenter-exec.js.map +1 -1
  228. package/dist/tools/pod-script.js +10 -19
  229. package/dist/tools/pod-script.js.map +1 -1
  230. package/dist/tools/restricted-bash.d.ts +1 -17
  231. package/dist/tools/restricted-bash.js +38 -252
  232. package/dist/tools/restricted-bash.js.map +1 -1
  233. package/dist/tools/run-skill.d.ts +3 -1
  234. package/dist/tools/run-skill.js +21 -1
  235. package/dist/tools/run-skill.js.map +1 -1
  236. package/dist/tools/script-resolver.d.ts +3 -1
  237. package/dist/tools/script-resolver.js +74 -30
  238. package/dist/tools/script-resolver.js.map +1 -1
  239. package/dist/tools/update-skill.js +17 -6
  240. package/dist/tools/update-skill.js.map +1 -1
  241. package/package.json +8 -6
  242. package/siclaw.mjs +10 -1
  243. package/skills/core/cluster-events/SKILL.md +1 -1
  244. package/skills/core/deep-investigation/SKILL.md +11 -0
  245. package/skills/core/deployment-rollout-debug/SKILL.md +1 -1
  246. package/skills/core/dns-debug/SKILL.md +1 -0
  247. package/skills/core/meta.json +12 -1
  248. package/skills/core/networkpolicy-debug/SKILL.md +332 -0
  249. package/skills/core/node-logs/scripts/get-node-logs.sh +19 -9
  250. package/skills/core/pod-pending-debug/SKILL.md +1 -0
  251. package/skills/core/quota-debug/SKILL.md +203 -0
  252. package/skills/core/service-debug/SKILL.md +1 -0
  253. package/skills/core/statefulset-debug/SKILL.md +280 -0
  254. package/skills/core/volcano-diagnose-pod/SKILL.md +196 -0
  255. package/skills/core/volcano-diagnose-pod/scripts/diagnose-pod.sh +175 -0
  256. package/skills/core/volcano-gang-scheduling/SKILL.md +299 -0
  257. package/skills/core/volcano-job-diagnose/SKILL.md +319 -0
  258. package/skills/core/volcano-job-diagnose/scripts/diagnose-job.sh +253 -0
  259. package/skills/core/volcano-node-resources/SKILL.md +334 -0
  260. package/skills/core/volcano-node-resources/scripts/get-node-resources.sh +281 -0
  261. package/skills/core/volcano-queue-diagnose/SKILL.md +294 -0
  262. package/skills/core/volcano-queue-diagnose/scripts/diagnose-queue.sh +283 -0
  263. package/skills/core/volcano-resource-insufficient/SKILL.md +315 -0
  264. package/skills/core/volcano-scheduler-config/SKILL.md +371 -0
  265. package/skills/core/volcano-scheduler-config/scripts/get-scheduler-config.sh +297 -0
  266. package/skills/core/volcano-scheduler-logs/SKILL.md +241 -0
  267. package/skills/core/volcano-scheduler-logs/scripts/get-scheduler-logs.sh +159 -0
  268. package/skills/platform/create-skill/SKILL.md +35 -3
  269. package/skills/platform/manage-skill/SKILL.md +9 -2
  270. package/skills/platform/update-skill/SKILL.md +17 -6
@@ -7,6 +7,7 @@
7
7
  import crypto from "node:crypto";
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
10
11
  import { AgentBoxClient } from "./agentbox/client.js";
11
12
  import { ChatRepository } from "./db/repositories/chat-repo.js";
12
13
  import { SkillRepository } from "./db/repositories/skill-repo.js";
@@ -20,6 +21,8 @@ import { ModelConfigRepository } from "./db/repositories/model-config-repo.js";
20
21
  import { CredentialRepository } from "./db/repositories/credential-repo.js";
21
22
  import { WorkspaceRepository } from "./db/repositories/workspace-repo.js";
22
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";
23
26
  import { getLabelsForSkill, batchGetLabels, listAllLabels } from "./skill-labels.js";
24
27
  import { McpServerRepository } from "./db/repositories/mcp-server-repo.js";
25
28
  import { SkillFileWriter } from "./skills/file-writer.js";
@@ -28,10 +31,11 @@ import { ScriptEvaluator } from "./skills/script-evaluator.js";
28
31
  import { SkillVersionRepository } from "./db/repositories/skill-version-repo.js";
29
32
  import { createTwoFilesPatch } from "diff";
30
33
  import yaml from "js-yaml";
31
- import { notifyCronService as notifyCronServiceImpl } from "./cron/notify.js";
32
34
  import { buildSkillBundle } from "./skills/skill-bundle.js";
33
35
  import { buildRedactionConfig, redactText } from "./output-redactor.js";
34
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";
35
39
  function requireAuth(context) {
36
40
  const userId = context.auth?.userId;
37
41
  if (!userId)
@@ -40,11 +44,32 @@ function requireAuth(context) {
40
44
  }
41
45
  function requireAdmin(context) {
42
46
  const userId = requireAuth(context);
43
- if (context.auth?.username !== "admin")
47
+ if (!isAdminUser(context))
44
48
  throw new Error("Forbidden: admin access required");
45
49
  return userId;
46
50
  }
47
- 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) {
48
73
  const methods = new Map();
49
74
  // Initialize repositories (null-safe — methods check before use)
50
75
  const chatRepo = db ? new ChatRepository(db) : null;
@@ -62,13 +87,15 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
62
87
  const sysConfigRepo = db ? new SystemConfigRepository(db) : null;
63
88
  const mcpRepo = db ? new McpServerRepository(db) : null;
64
89
  const skillContentRepo = db ? new SkillContentRepository(db) : null;
90
+ const envRepo = db ? new EnvironmentRepository(db) : null;
91
+ const userEnvConfigRepo = db ? new UserEnvConfigRepository(db) : null;
65
92
  const scriptEvaluator = new ScriptEvaluator(modelConfigRepo);
66
- /** Resolve workspaceId for a session from DB, falling back to "default" */
93
+ /** Resolve workspaceId for a session from DB */
67
94
  async function resolveSessionWorkspace(sessionId) {
68
95
  if (!chatRepo)
69
- return "default";
96
+ return undefined;
70
97
  const session = await chatRepo.getSession(sessionId);
71
- return session?.workspaceId ?? "default";
98
+ return session?.workspaceId ?? undefined;
72
99
  }
73
100
  /** Find an AgentBox handle for a user, trying session workspace first, then any active box */
74
101
  async function findAgentBoxForSession(userId, sessionId) {
@@ -119,6 +146,56 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
119
146
  console.warn(`[resource-notify] All skill reload failed:`, err.message);
120
147
  });
121
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
+ }
122
199
  // Initialize skills dir
123
200
  skillWriter.init()
124
201
  .then(async () => {
@@ -127,10 +204,13 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
127
204
  .catch((err) => {
128
205
  console.error("[rpc] Failed to initialize skills:", err);
129
206
  });
130
- // Resolve core/extension skills directory: prefer baked-in image path over NFS
131
- const builtinCoreDir = path.join(process.cwd(), "skills", "core");
207
+ // Resolve core/extension skills directory: prefer baked-in package path over NFS
208
+ // Use import.meta.url to locate the npm package root (dist/gateway/rpc-methods.js package root)
209
+ const __rpcDirname = path.dirname(fileURLToPath(import.meta.url));
210
+ const packageRoot = path.resolve(__rpcDirname, "..", "..");
211
+ const builtinCoreDir = path.join(packageRoot, "skills", "core");
132
212
  const coreSkillsDir = fs.existsSync(builtinCoreDir) ? builtinCoreDir : path.join(skillsDir, "core");
133
- const builtinExtDir = path.join(process.cwd(), "skills", "extension");
213
+ const builtinExtDir = path.join(packageRoot, "skills", "extension");
134
214
  const extSkillsDir = fs.existsSync(builtinExtDir) ? builtinExtDir : path.join(skillsDir, "extension");
135
215
  /** Resolve the filesystem dir for a builtin skill dirName (core first, then extension) */
136
216
  function resolveBuiltinSkillDir(dirName) {
@@ -265,7 +345,9 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
265
345
  workspace = null;
266
346
  }
267
347
  }
268
- const effectiveWorkspaceId = workspace?.id ?? "default";
348
+ if (!workspace)
349
+ throw new Error("Failed to resolve workspace");
350
+ const effectiveWorkspaceId = workspace.id;
269
351
  // Ensure session exists in DB
270
352
  if (chatRepo) {
271
353
  if (sessionId) {
@@ -292,7 +374,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
292
374
  });
293
375
  }
294
376
  if (!sessionId)
295
- sessionId = "default";
377
+ throw new Error("Failed to create session");
296
378
  // Forward model selection and brain type from frontend
297
379
  // Use workspace default model if user didn't specify one
298
380
  const modelProvider = params.modelProvider ?? workspace?.configJson?.defaultModel?.provider;
@@ -327,9 +409,12 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
327
409
  }
328
410
  }
329
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");
330
414
  const handle = await agentBoxManager.getOrCreate(userId, effectiveWorkspaceId, {
331
415
  workspaceId: effectiveWorkspaceId,
332
416
  allowedTools,
417
+ podEnv,
333
418
  });
334
419
  const client = new AgentBoxClient(handle.endpoint, 30000, agentBoxTlsOptions);
335
420
  // Send prompt
@@ -361,7 +446,9 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
361
446
  // Async SSE processing
362
447
  (async () => {
363
448
  let assistantContent = "";
364
- 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();
365
452
  let sseEventCount = 0;
366
453
  const sseStartTime = Date.now();
367
454
  // Mark user as having an active prompt so WS teardown won't kill the pod
@@ -387,15 +474,32 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
387
474
  .map((c) => c.text ?? "")
388
475
  .join("") ?? "";
389
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) || "";
390
490
  dbMessageId = await chatRepo.appendMessage({
391
491
  sessionId: result.sessionId,
392
492
  role: "tool",
393
493
  content: redactText(text, redactionConfig),
394
494
  toolName,
395
- toolInput: pendingToolInput ? redactText(pendingToolInput, redactionConfig) : undefined,
495
+ toolInput: toolInput ? redactText(toolInput, redactionConfig) : undefined,
496
+ userId,
497
+ outcome,
498
+ durationMs,
396
499
  });
397
500
  await chatRepo.incrementMessageCount(result.sessionId);
398
- pendingToolInput = "";
501
+ pendingToolInputs.delete(toolName);
502
+ pendingToolStartTimes.delete(toolName);
399
503
  }
400
504
  // Forward event to frontend via sendToUser (targets all WS connections
401
505
  // for this user, so reconnected sessions also receive live events)
@@ -465,9 +569,11 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
465
569
  }
466
570
  }
467
571
  else if (eventType === "tool_execution_start") {
468
- // 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";
469
574
  const args = eventData.args;
470
- pendingToolInput = args ? JSON.stringify(args) : "";
575
+ pendingToolInputs.set(startToolName, args ? JSON.stringify(args) : "");
576
+ pendingToolStartTimes.set(startToolName, Date.now());
471
577
  }
472
578
  // tool_execution_end already handled above
473
579
  }
@@ -595,9 +701,11 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
595
701
  const providerName = params.provider;
596
702
  const baseUrl = params.baseUrl;
597
703
  const apiKey = params.apiKey;
704
+ const api = params.api;
705
+ const authHeader = params.authHeader;
598
706
  if (!providerName)
599
707
  throw new Error("Missing provider name");
600
- await modelConfigRepo.saveProvider(providerName, baseUrl, apiKey);
708
+ await modelConfigRepo.saveProvider(providerName, baseUrl, apiKey, api, authHeader);
601
709
  return { ok: true };
602
710
  });
603
711
  methods.set("provider.delete", async (params, context) => {
@@ -643,6 +751,113 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
643
751
  await modelConfigRepo.removeModel(providerName, modelId);
644
752
  return { ok: true };
645
753
  });
754
+ methods.set("provider.testConnection", async (params, context) => {
755
+ requireAdmin(context);
756
+ const baseUrl = params.baseUrl;
757
+ const apiKey = params.apiKey;
758
+ const api = params.api ?? "openai-completions";
759
+ if (!baseUrl || !apiKey)
760
+ throw new Error("Missing required params: baseUrl, apiKey");
761
+ const controller = new AbortController();
762
+ const timeout = setTimeout(() => controller.abort(), 10000);
763
+ const base = baseUrl.replace(/\/+$/, "");
764
+ try {
765
+ if (api === "anthropic") {
766
+ // Anthropic: auth-only check via counting message tokens (free, no completion)
767
+ const res = await fetch(`${base}/v1/messages/count_tokens`, {
768
+ method: "POST",
769
+ headers: {
770
+ "Content-Type": "application/json",
771
+ "x-api-key": apiKey,
772
+ "anthropic-version": "2023-06-01",
773
+ },
774
+ body: JSON.stringify({
775
+ model: "claude-sonnet-4-20250514",
776
+ messages: [{ role: "user", content: "hi" }],
777
+ }),
778
+ signal: controller.signal,
779
+ });
780
+ if (res.ok)
781
+ return { ok: true, message: "Connection successful" };
782
+ // 404 means endpoint not available — try a simple auth header check
783
+ if (res.status === 404) {
784
+ // Any authenticated GET that returns non-401 means key is valid
785
+ const fallback = await fetch(`${base}/v1/models`, {
786
+ headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
787
+ signal: controller.signal,
788
+ });
789
+ if (fallback.status !== 401 && fallback.status !== 403) {
790
+ return { ok: true, message: "Connection successful" };
791
+ }
792
+ }
793
+ const body = await res.text().catch(() => "");
794
+ return { ok: false, message: `HTTP ${res.status}: ${body.slice(0, 200)}` };
795
+ }
796
+ // OpenAI-compatible: try GET /models (auth-only, no completion)
797
+ const modelsPaths = [`${base}/models`, `${base}/v1/models`];
798
+ for (const url of modelsPaths) {
799
+ const res = await fetch(url, {
800
+ headers: { Authorization: `Bearer ${apiKey}` },
801
+ signal: controller.signal,
802
+ });
803
+ if (res.ok) {
804
+ return { ok: true, message: "Connection successful" };
805
+ }
806
+ // 401/403 = bad key — definitive failure
807
+ if (res.status === 401 || res.status === 403) {
808
+ const body = await res.text().catch(() => "");
809
+ return { ok: false, message: `Authentication failed (HTTP ${res.status})` };
810
+ }
811
+ // 404 = endpoint not found but server responded — try next path
812
+ }
813
+ // All paths returned 404: server is reachable and didn't reject the key
814
+ return { ok: true, message: "Connection successful" };
815
+ }
816
+ catch (err) {
817
+ const msg = err instanceof Error ? err.message : String(err);
818
+ return { ok: false, message: msg.includes("abort") ? "Connection timed out (10s)" : msg };
819
+ }
820
+ finally {
821
+ clearTimeout(timeout);
822
+ }
823
+ });
824
+ methods.set("provider.quickSetup", async (params, context) => {
825
+ requireAdmin(context);
826
+ if (!modelConfigRepo)
827
+ throw new Error("Database not available");
828
+ const providerName = params.provider;
829
+ const baseUrl = params.baseUrl;
830
+ const apiKey = params.apiKey;
831
+ const api = params.api ?? "openai-completions";
832
+ const authHeader = params.authHeader ?? false;
833
+ const model = params.model;
834
+ const setAsDefault = params.setAsDefault ?? true;
835
+ if (!providerName)
836
+ throw new Error("Missing provider name");
837
+ // 1. Save provider
838
+ await modelConfigRepo.saveProvider(providerName, baseUrl, apiKey, api, authHeader);
839
+ // 2. Add model if provided
840
+ if (model?.id && model?.name) {
841
+ try {
842
+ await modelConfigRepo.addModel(providerName, {
843
+ id: model.id,
844
+ name: model.name,
845
+ reasoning: model.reasoning ?? false,
846
+ contextWindow: model.contextWindow ?? 128000,
847
+ maxTokens: model.maxTokens ?? 65536,
848
+ category: model.category ?? "llm",
849
+ });
850
+ }
851
+ catch {
852
+ // Model may already exist (e.g. re-running quick setup) — that's fine
853
+ }
854
+ // 3. Set as default
855
+ if (setAsDefault) {
856
+ await modelConfigRepo.setDefault(providerName, model.id);
857
+ }
858
+ }
859
+ return { ok: true };
860
+ });
646
861
  methods.set("config.getDefaultModel", async (_params, context) => {
647
862
  requireAuth(context);
648
863
  if (!modelConfigRepo)
@@ -888,12 +1103,14 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
888
1103
  const userId = requireAuth(context);
889
1104
  const sessionId = params.sessionId;
890
1105
  const snapKey = sessionId ? `${userId}:${sessionId}` : userId;
1106
+ const streamKey = sessionId ? `${userId}:${sessionId}` : undefined;
1107
+ const promptActive = streamKey ? activeStreams.has(streamKey) : false;
891
1108
  const snap = dpProgressSnapshots.get(snapKey);
892
1109
  if (!snap || Date.now() - snap.updatedAt > 600_000) {
893
1110
  dpProgressSnapshots.delete(snapKey);
894
- return { events: null };
1111
+ return { events: null, promptActive };
895
1112
  }
896
- return { sessionId: snap.sessionId, events: snap.events };
1113
+ return { sessionId: snap.sessionId, events: snap.events, promptActive };
897
1114
  });
898
1115
  methods.set("chat.history", async (params, context) => {
899
1116
  const userId = requireAuth(context);
@@ -943,34 +1160,27 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
943
1160
  // ─────────────────────────────────────────────────
944
1161
  methods.set("session.list", async (params, context) => {
945
1162
  const userId = requireAuth(context);
1163
+ if (!chatRepo)
1164
+ throw new Error("Database not available");
946
1165
  const workspaceId = params?.workspaceId;
947
- if (chatRepo) {
948
- const rows = await chatRepo.listSessions(userId, 20, workspaceId);
949
- return {
950
- sessions: rows.map((s) => ({
951
- key: s.id,
952
- title: s.title,
953
- preview: s.preview,
954
- createdAt: s.createdAt?.toISOString(),
955
- lastActiveAt: s.lastActiveAt?.toISOString(),
956
- messageCount: s.messageCount,
957
- })),
958
- };
959
- }
960
- // Fallback: get from AgentBox
961
- const handle = await agentBoxManager.getAsync(userId, workspaceId ?? "default");
962
- if (!handle)
963
- return { sessions: [] };
964
- const client = new AgentBoxClient(handle.endpoint, 30000, agentBoxTlsOptions);
965
- 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
+ };
966
1177
  });
967
1178
  methods.set("session.create", async (_params, context) => {
968
1179
  const userId = requireAuth(context);
969
- if (chatRepo) {
970
- const session = await chatRepo.createSession(userId);
971
- return { sessionId: session.id, sessionKey: session.id };
972
- }
973
- 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 };
974
1184
  });
975
1185
  methods.set("session.delete", async (params, context) => {
976
1186
  const userId = requireAuth(context);
@@ -987,7 +1197,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
987
1197
  // ─────────────────────────────────────────────────
988
1198
  methods.set("box.status", async (params, context) => {
989
1199
  const userId = requireAuth(context);
990
- const workspaceId = params?.workspaceId ?? "default";
1200
+ const workspaceId = params?.workspaceId;
991
1201
  const handle = await agentBoxManager.getAsync(userId, workspaceId);
992
1202
  if (!handle)
993
1203
  return { boxStatus: "not_created" };
@@ -1824,12 +2034,17 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1824
2034
  await skillRepo.update(skillId, updates);
1825
2035
  }
1826
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
+ }
1827
2041
  let stagedFiles = null;
1828
2042
  if (skillContentRepo) {
1829
2043
  stagedFiles = await skillContentRepo.read(skillId, "staging");
1830
2044
  }
1831
- if (stagedFiles?.scripts?.length) {
1832
- 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);
1833
2048
  }
1834
2049
  // 6. Notify reviewers (only on first submit to avoid flooding)
1835
2050
  if (!isPending) {
@@ -1865,14 +2080,17 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
1865
2080
  if (currentReviewStatus !== "pending") {
1866
2081
  throw new Error("This skill is not pending review");
1867
2082
  }
1868
- // Record reviewer decision
2083
+ // Record reviewer decision — inherit riskLevel from the latest AI review if available
1869
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";
1870
2088
  await skillReviewRepo.create({
1871
2089
  skillId,
1872
2090
  version: meta.version,
1873
2091
  reviewerType: "admin",
1874
2092
  reviewerId,
1875
- riskLevel: "low",
2093
+ riskLevel,
1876
2094
  summary: reason || (decision === "approve" ? "Approved by reviewer" : "Rejected by reviewer"),
1877
2095
  findings: [],
1878
2096
  decision,
@@ -2092,19 +2310,27 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2092
2310
  // ─────────────────────────────────────────────────
2093
2311
  // Cron Job Methods
2094
2312
  // ─────────────────────────────────────────────────
2095
- /** Notify all cron instances of job changes (fire-and-forget) */
2096
- function notifyCronService(payload) {
2097
- notifyCronServiceImpl(payload, configRepo);
2098
- }
2099
- methods.set("cron.list", async (_params, context) => {
2313
+ methods.set("cron.list", async (params, context) => {
2100
2314
  const userId = requireAuth(context);
2101
2315
  if (!configRepo)
2102
2316
  return { jobs: [] };
2103
- 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
+ }
2104
2330
  return {
2105
2331
  jobs: rows.map((r) => ({
2106
2332
  ...r,
2107
- envName: null,
2333
+ workspaceName: r.workspaceId ? (wsNameMap.get(r.workspaceId) ?? null) : null,
2108
2334
  })),
2109
2335
  };
2110
2336
  });
@@ -2116,10 +2342,8 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2116
2342
  const description = params.description;
2117
2343
  const schedule = params.schedule;
2118
2344
  const status = params.status ?? "active";
2119
- // For updates: fetch existing job to get current assignedTo
2120
2345
  const existingId = params.id;
2121
- const existingJob = existingId ? await configRepo.getCronJobById(existingId) : null;
2122
- const envId = params.envId;
2346
+ const workspaceId = params.workspaceId;
2123
2347
  const id = await configRepo.saveCronJob(userId, {
2124
2348
  id: existingId,
2125
2349
  name,
@@ -2127,38 +2351,20 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2127
2351
  schedule,
2128
2352
  skillId: params.skillId,
2129
2353
  status,
2130
- envId: envId ?? null,
2354
+ workspaceId: workspaceId ?? null,
2131
2355
  });
2132
- if (status === "paused") {
2133
- // Pausing just cancel the timer, don't reassign
2134
- notifyCronService({ action: "pause", jobId: id });
2135
- }
2136
- else {
2137
- // Active — keep existing assignment if possible, otherwise assign to least-loaded
2138
- let assignedTo = existingJob?.assignedTo ?? null;
2139
- if (!assignedTo) {
2140
- try {
2141
- const leastLoaded = await configRepo.getLeastLoadedInstance();
2142
- if (leastLoaded) {
2143
- assignedTo = leastLoaded.instanceId;
2144
- }
2145
- }
2146
- catch {
2147
- // No instances available yet — coordinator will pick up unassigned jobs
2148
- }
2149
- }
2150
- if (assignedTo) {
2151
- await configRepo.assignCronJob(id, assignedTo);
2356
+ if (cronService) {
2357
+ if (status === "paused") {
2358
+ cronService.cancel(id);
2152
2359
  }
2153
- notifyCronService({
2154
- action: "upsert",
2155
- job: {
2360
+ else {
2361
+ cronService.addOrUpdate({
2156
2362
  id, userId, name, description: description ?? null, schedule, status,
2157
- skillId: params.skillId ?? null, assignedTo,
2363
+ skillId: params.skillId ?? null, assignedTo: null,
2158
2364
  lastRunAt: null, lastResult: null, lockedBy: null, lockedAt: null,
2159
- envId: envId ?? null,
2160
- },
2161
- });
2365
+ workspaceId: workspaceId ?? null,
2366
+ });
2367
+ }
2162
2368
  }
2163
2369
  return { id, name, schedule, status };
2164
2370
  });
@@ -2176,7 +2382,13 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2176
2382
  if (job.userId !== userId)
2177
2383
  throw new Error("Forbidden");
2178
2384
  await configRepo.deleteCronJob(id);
2179
- 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
+ }
2180
2392
  return { status: "deleted" };
2181
2393
  });
2182
2394
  methods.set("cron.setStatus", async (params, context) => {
@@ -2204,21 +2416,23 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2204
2416
  schedule: job.schedule,
2205
2417
  skillId: job.skillId ?? undefined,
2206
2418
  status,
2207
- envId: job.envId ?? null,
2419
+ workspaceId: job.workspaceId ?? null,
2208
2420
  });
2209
- // Notify cron service
2210
- notifyCronService({
2211
- action: status === "paused" ? "pause" : "upsert",
2212
- ...(status === "paused" ? { jobId: id } : {
2213
- job: {
2421
+ // Update scheduler
2422
+ if (cronService) {
2423
+ if (status === "paused") {
2424
+ cronService.cancel(id);
2425
+ }
2426
+ else {
2427
+ cronService.addOrUpdate({
2214
2428
  id, userId, name: job.name, description: job.description ?? null,
2215
2429
  schedule: job.schedule, status, skillId: job.skillId ?? null,
2216
- assignedTo: job.assignedTo ?? null,
2217
- lastRunAt: null, lastResult: null, lockedBy: null, lockedAt: null,
2218
- envId: job.envId ?? null,
2219
- },
2220
- }),
2221
- });
2430
+ assignedTo: null, lastRunAt: null, lastResult: null,
2431
+ lockedBy: null, lockedAt: null,
2432
+ workspaceId: job.workspaceId ?? null,
2433
+ });
2434
+ }
2435
+ }
2222
2436
  return { id, status };
2223
2437
  });
2224
2438
  methods.set("cron.rename", async (params, context) => {
@@ -2245,21 +2459,46 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2245
2459
  schedule: job.schedule,
2246
2460
  skillId: job.skillId ?? undefined,
2247
2461
  status: job.status,
2248
- envId: job.envId ?? null,
2462
+ workspaceId: job.workspaceId ?? null,
2249
2463
  });
2250
- // Notify cron service
2251
- notifyCronService({
2252
- action: "upsert",
2253
- job: {
2464
+ // Update scheduler (name change — reschedule with updated job data)
2465
+ if (cronService && job.status === "active") {
2466
+ cronService.addOrUpdate({
2254
2467
  id, userId, name: newName.trim(), description: job.description ?? null,
2255
- schedule: job.schedule, status: job.status, skillId: job.skillId ?? null,
2256
- assignedTo: job.assignedTo ?? null,
2468
+ schedule: job.schedule, status: job.status,
2469
+ skillId: job.skillId ?? null, assignedTo: null,
2257
2470
  lastRunAt: null, lastResult: null, lockedBy: null, lockedAt: null,
2258
- envId: job.envId ?? null,
2259
- },
2260
- });
2471
+ workspaceId: job.workspaceId ?? null,
2472
+ });
2473
+ }
2261
2474
  return { id, name: newName.trim() };
2262
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
+ });
2263
2502
  // ─────────────────────────────────────────────────
2264
2503
  // Trigger Methods
2265
2504
  // ─────────────────────────────────────────────────
@@ -2707,6 +2946,9 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2707
2946
  const configJson = params.configJson;
2708
2947
  if (!name)
2709
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
+ }
2710
2952
  if (!type || !CREDENTIAL_TYPES.includes(type)) {
2711
2953
  throw new Error(`Invalid credential type. Must be one of: ${CREDENTIAL_TYPES.join(", ")}`);
2712
2954
  }
@@ -2714,6 +2956,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2714
2956
  throw new Error("Missing required param: configJson");
2715
2957
  validateCredentialConfig(type, configJson);
2716
2958
  const id = await credRepo.create({ userId, name, type, description, configJson });
2959
+ pushCredentialsToUser(userId);
2717
2960
  return { id, name, type };
2718
2961
  });
2719
2962
  methods.set("credential.update", async (params, context) => {
@@ -2737,6 +2980,7 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2737
2980
  updates.configJson = configJson;
2738
2981
  }
2739
2982
  await credRepo.update(userId, id, updates);
2983
+ pushCredentialsToUser(userId);
2740
2984
  return { status: "updated" };
2741
2985
  });
2742
2986
  methods.set("credential.delete", async (params, context) => {
@@ -2750,9 +2994,253 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2750
2994
  if (!existing)
2751
2995
  throw new Error("Credential not found");
2752
2996
  await credRepo.delete(userId, id);
2997
+ pushCredentialsToUser(userId);
2998
+ return { status: "deleted" };
2999
+ });
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
+ }
2753
3161
  return { status: "deleted" };
2754
3162
  });
2755
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
+ // ─────────────────────────────────────────────────
2756
3244
  // Workspace Methods
2757
3245
  // ─────────────────────────────────────────────────
2758
3246
  methods.set("workspace.list", async (_params, context) => {
@@ -2762,7 +3250,14 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2762
3250
  let list = await workspaceRepo.list(userId);
2763
3251
  // Auto-create default workspace if none exists
2764
3252
  if (list.length === 0) {
2765
- 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);
2766
3261
  list = await workspaceRepo.list(userId);
2767
3262
  }
2768
3263
  return { workspaces: list };
@@ -2774,8 +3269,18 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2774
3269
  const name = params.name;
2775
3270
  if (!name)
2776
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
+ }
2777
3282
  const config = params.config;
2778
- const ws = await workspaceRepo.create(userId, name, config);
3283
+ const ws = await workspaceRepo.create(userId, name, config, envType);
2779
3284
  // Build workspace skills directory
2780
3285
  await syncWorkspaceSkills(userId, ws.id, ws.isDefault, [], []);
2781
3286
  return { workspace: ws };
@@ -2796,6 +3301,31 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2796
3301
  updates.name = params.name;
2797
3302
  if (params.config !== undefined)
2798
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
+ }
2799
3329
  await workspaceRepo.update(id, updates);
2800
3330
  return { status: "updated" };
2801
3331
  });
@@ -2827,16 +3357,25 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2827
3357
  const ws = await workspaceRepo.getById(id);
2828
3358
  if (!ws || ws.userId !== userId)
2829
3359
  throw new Error("Workspace not found");
2830
- const [wsSkills, wsTools, wsCreds] = await Promise.all([
3360
+ const [wsSkills, wsTools, wsCreds, wsEnvIds] = await Promise.all([
2831
3361
  workspaceRepo.getSkills(id),
2832
3362
  workspaceRepo.getTools(id),
2833
3363
  workspaceRepo.getCredentials(id),
3364
+ workspaceRepo.getEnvironments(id),
2834
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
+ }
2835
3372
  return {
2836
3373
  workspace: ws,
2837
3374
  skills: wsSkills,
2838
3375
  tools: wsTools,
2839
3376
  credentials: wsCreds,
3377
+ environments: wsEnvIds,
3378
+ environmentDetails: envDetails,
2840
3379
  };
2841
3380
  });
2842
3381
  methods.set("workspace.setSkills", async (params, context) => {
@@ -2903,6 +3442,50 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2903
3442
  }
2904
3443
  }
2905
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);
2906
3489
  return { status: "updated" };
2907
3490
  });
2908
3491
  methods.set("workspace.availableTools", async (_params, context) => {
@@ -2928,8 +3511,12 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2928
3511
  });
2929
3512
  /**
2930
3513
  * Build credential payload for a workspace.
2931
- * Default workspace: returns ALL user credentials.
2932
- * 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
+ *
2933
3520
  * Returns data only — does NOT write to disk. Agentbox materializes files locally.
2934
3521
  */
2935
3522
  async function buildCredentialPayload(userId, workspaceId, isDefault) {
@@ -2938,16 +3525,83 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2938
3525
  if (!fs.existsSync(agentDataDir)) {
2939
3526
  fs.mkdirSync(agentDataDir, { recursive: true });
2940
3527
  }
2941
- if (!credRepo)
2942
- return { manifest: [], files: [] };
2943
- // 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
+ }
2944
3598
  let creds;
2945
3599
  if (isDefault) {
2946
3600
  creds = await credRepo.listForUser(userId);
2947
3601
  }
2948
3602
  else {
2949
3603
  if (!workspaceRepo)
2950
- return { manifest: [], files: [] };
3604
+ return { manifest, files };
2951
3605
  const linkedIds = await workspaceRepo.getCredentials(workspaceId);
2952
3606
  if (linkedIds.length === 0) {
2953
3607
  creds = [];
@@ -2958,43 +3612,15 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
2958
3612
  }
2959
3613
  // IdentityFile path used inside agentbox (resolves to .siclaw/credentials/)
2960
3614
  const credsDirInBox = path.resolve(process.cwd(), ".siclaw/credentials");
2961
- const manifest = [];
2962
- const files = [];
2963
3615
  for (const cred of creds) {
3616
+ // Skip kubeconfig type credentials (now handled via environments)
3617
+ if (cred.type === "kubeconfig")
3618
+ continue;
2964
3619
  const config = (cred.configJson ?? {});
2965
3620
  const safeName = cred.name.replace(/[^a-zA-Z0-9_-]/g, "_");
2966
3621
  const fileNames = [];
2967
3622
  let metadata;
2968
3623
  switch (cred.type) {
2969
- case "kubeconfig": {
2970
- const content = config.content;
2971
- if (content) {
2972
- const filename = `${safeName}.kubeconfig`;
2973
- files.push({ name: filename, content });
2974
- fileNames.push(filename);
2975
- try {
2976
- const kc = yaml.load(content);
2977
- const clusters = kc?.clusters ?? [];
2978
- const contexts = kc?.contexts ?? [];
2979
- metadata = {
2980
- clusters: clusters.map((c) => ({
2981
- name: c.name,
2982
- server: c.cluster?.server,
2983
- })),
2984
- contexts: contexts.map((c) => ({
2985
- name: c.name,
2986
- cluster: c.context?.cluster,
2987
- namespace: c.context?.namespace,
2988
- })),
2989
- currentContext: kc?.["current-context"],
2990
- };
2991
- }
2992
- catch {
2993
- // ignore parse errors
2994
- }
2995
- }
2996
- break;
2997
- }
2998
3624
  case "ssh_key": {
2999
3625
  const privateKey = config.privateKey;
3000
3626
  if (privateKey) {
@@ -3124,11 +3750,17 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3124
3750
  const sessions = await chatRepo.listSessions(userId, 1);
3125
3751
  sessionCount = sessions.length;
3126
3752
  }
3127
- // 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.
3128
3755
  const userDataDir = process.env.SICLAW_USER_DATA_DIR || ".siclaw/user-data";
3129
3756
  const profilePath = path.resolve(userDataDir, "memory", "PROFILE.md");
3130
- const hasProfile = fs.existsSync(profilePath);
3131
- // 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)
3132
3764
  const credentials = {};
3133
3765
  if (credRepo) {
3134
3766
  const creds = await credRepo.listForUser(userId);
@@ -3136,6 +3768,20 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3136
3768
  credentials[c.type] = (credentials[c.type] || 0) + 1;
3137
3769
  }
3138
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
+ }
3139
3785
  return { hasModels, hasProfile, sessionCount, credentials };
3140
3786
  });
3141
3787
  // ─────────────────────────────────────────────────
@@ -3156,7 +3802,8 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3156
3802
  const values = params.values;
3157
3803
  const ALLOWED_SECTIONS = {
3158
3804
  sso: ["sso.enabled", "sso.issuer", "sso.clientId", "sso.clientSecret", "sso.redirectUri"],
3159
- system: ["system.baseUrl", "system.platformUrl", "system.agentboxImage"],
3805
+ system: ["system.grafanaUrl"],
3806
+ metrics: ["metrics.port", "metrics.token", "metrics.includeUserId"],
3160
3807
  };
3161
3808
  const allowedKeys = ALLOWED_SECTIONS[section];
3162
3809
  if (!allowedKeys)
@@ -3170,29 +3817,181 @@ export function createRpcMethods(agentBoxManager, broadcast, db, sendToUser, act
3170
3817
  }
3171
3818
  }
3172
3819
  await sysConfigRepo.setMany(entries);
3173
- // Apply agentbox image change at runtime
3174
- if (section === "system" && entries["system.agentboxImage"]) {
3175
- agentBoxManager.setSpawnerImage(entries["system.agentboxImage"]);
3176
- }
3177
3820
  return { ok: true };
3178
3821
  });
3179
- /** 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). */
3180
3824
  async function getSkillBundle(userId, env) {
3181
3825
  if (!skillRepo || !skillContentRepo)
3182
3826
  throw new Error("Database not available");
3183
3827
  const disabled = new Set(await skillRepo.listDisabledSkills(userId));
3184
- 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);
3185
3831
  }
3186
- /** 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. */
3187
3834
  function cleanupForWs(ws) {
3188
3835
  for (const [key, stream] of activeStreams.entries()) {
3189
3836
  if (stream.ws === ws) {
3190
- console.log(`[rpc] Cleaning up SSE stream ${key} (WS closed)`);
3191
- stream.abort();
3192
- activeStreams.delete(key);
3837
+ console.log(`[rpc] WS detached from SSE stream ${key} SSE continues`);
3838
+ stream.ws = undefined;
3193
3839
  }
3194
3840
  }
3195
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
+ });
3196
3995
  return { methods, buildCredentialPayload, getSkillBundle, cleanupForWs };
3197
3996
  }
3198
3997
  //# sourceMappingURL=rpc-methods.js.map