@data-club/ai-hub-server 0.0.1

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 (248) hide show
  1. package/dist/auth/argon2.d.ts +72 -0
  2. package/dist/auth/argon2.d.ts.map +1 -0
  3. package/dist/auth/argon2.js +145 -0
  4. package/dist/auth/argon2.js.map +1 -0
  5. package/dist/auth/encryption.d.ts +52 -0
  6. package/dist/auth/encryption.d.ts.map +1 -0
  7. package/dist/auth/encryption.js +98 -0
  8. package/dist/auth/encryption.js.map +1 -0
  9. package/dist/auth/jwt.d.ts +133 -0
  10. package/dist/auth/jwt.d.ts.map +1 -0
  11. package/dist/auth/jwt.js +147 -0
  12. package/dist/auth/jwt.js.map +1 -0
  13. package/dist/auth/middleware.d.ts +101 -0
  14. package/dist/auth/middleware.d.ts.map +1 -0
  15. package/dist/auth/middleware.js +161 -0
  16. package/dist/auth/middleware.js.map +1 -0
  17. package/dist/auth/passwordPolicy.d.ts +72 -0
  18. package/dist/auth/passwordPolicy.d.ts.map +1 -0
  19. package/dist/auth/passwordPolicy.js +88 -0
  20. package/dist/auth/passwordPolicy.js.map +1 -0
  21. package/dist/auth/roleRules.d.ts +200 -0
  22. package/dist/auth/roleRules.d.ts.map +1 -0
  23. package/dist/auth/roleRules.js +365 -0
  24. package/dist/auth/roleRules.js.map +1 -0
  25. package/dist/auth/userView.d.ts +55 -0
  26. package/dist/auth/userView.d.ts.map +1 -0
  27. package/dist/auth/userView.js +77 -0
  28. package/dist/auth/userView.js.map +1 -0
  29. package/dist/bootstrap.d.ts +104 -0
  30. package/dist/bootstrap.d.ts.map +1 -0
  31. package/dist/bootstrap.js +160 -0
  32. package/dist/bootstrap.js.map +1 -0
  33. package/dist/db/connection.d.ts +39 -0
  34. package/dist/db/connection.d.ts.map +1 -0
  35. package/dist/db/connection.js +62 -0
  36. package/dist/db/connection.js.map +1 -0
  37. package/dist/db/migrate.d.ts +31 -0
  38. package/dist/db/migrate.d.ts.map +1 -0
  39. package/dist/db/migrate.js +197 -0
  40. package/dist/db/migrate.js.map +1 -0
  41. package/dist/db/schema.d.ts +1902 -0
  42. package/dist/db/schema.d.ts.map +1 -0
  43. package/dist/db/schema.js +662 -0
  44. package/dist/db/schema.js.map +1 -0
  45. package/dist/db/seed.d.ts +92 -0
  46. package/dist/db/seed.d.ts.map +1 -0
  47. package/dist/db/seed.js +160 -0
  48. package/dist/db/seed.js.map +1 -0
  49. package/dist/handlers/fetch.d.ts +89 -0
  50. package/dist/handlers/fetch.d.ts.map +1 -0
  51. package/dist/handlers/fetch.js +173 -0
  52. package/dist/handlers/fetch.js.map +1 -0
  53. package/dist/handlers/nextjs.d.ts +40 -0
  54. package/dist/handlers/nextjs.d.ts.map +1 -0
  55. package/dist/handlers/nextjs.js +42 -0
  56. package/dist/handlers/nextjs.js.map +1 -0
  57. package/dist/handlers/responses.d.ts +46 -0
  58. package/dist/handlers/responses.d.ts.map +1 -0
  59. package/dist/handlers/responses.js +74 -0
  60. package/dist/handlers/responses.js.map +1 -0
  61. package/dist/handlers/routeTable.d.ts +21 -0
  62. package/dist/handlers/routeTable.d.ts.map +1 -0
  63. package/dist/handlers/routeTable.js +133 -0
  64. package/dist/handlers/routeTable.js.map +1 -0
  65. package/dist/handlers/router.d.ts +37 -0
  66. package/dist/handlers/router.d.ts.map +1 -0
  67. package/dist/handlers/router.js +82 -0
  68. package/dist/handlers/router.js.map +1 -0
  69. package/dist/handlers/sse.d.ts +38 -0
  70. package/dist/handlers/sse.d.ts.map +1 -0
  71. package/dist/handlers/sse.js +52 -0
  72. package/dist/handlers/sse.js.map +1 -0
  73. package/dist/handlers/types.d.ts +96 -0
  74. package/dist/handlers/types.d.ts.map +1 -0
  75. package/dist/handlers/types.js +14 -0
  76. package/dist/handlers/types.js.map +1 -0
  77. package/dist/index.d.ts +25 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +24 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/letta/client.d.ts +355 -0
  82. package/dist/letta/client.d.ts.map +1 -0
  83. package/dist/letta/client.js +529 -0
  84. package/dist/letta/client.js.map +1 -0
  85. package/dist/letta/eventTranslator.d.ts +166 -0
  86. package/dist/letta/eventTranslator.d.ts.map +1 -0
  87. package/dist/letta/eventTranslator.js +532 -0
  88. package/dist/letta/eventTranslator.js.map +1 -0
  89. package/dist/letta/index.d.ts +13 -0
  90. package/dist/letta/index.d.ts.map +1 -0
  91. package/dist/letta/index.js +12 -0
  92. package/dist/letta/index.js.map +1 -0
  93. package/dist/letta/modelSettings.d.ts +176 -0
  94. package/dist/letta/modelSettings.d.ts.map +1 -0
  95. package/dist/letta/modelSettings.js +314 -0
  96. package/dist/letta/modelSettings.js.map +1 -0
  97. package/dist/letta/promptWrapping.d.ts +141 -0
  98. package/dist/letta/promptWrapping.d.ts.map +1 -0
  99. package/dist/letta/promptWrapping.js +213 -0
  100. package/dist/letta/promptWrapping.js.map +1 -0
  101. package/dist/letta/sandbox.d.ts +100 -0
  102. package/dist/letta/sandbox.d.ts.map +1 -0
  103. package/dist/letta/sandbox.js +151 -0
  104. package/dist/letta/sandbox.js.map +1 -0
  105. package/dist/letta/settingsFileSeed.d.ts +146 -0
  106. package/dist/letta/settingsFileSeed.d.ts.map +1 -0
  107. package/dist/letta/settingsFileSeed.js +177 -0
  108. package/dist/letta/settingsFileSeed.js.map +1 -0
  109. package/dist/letta/subprocessThrottle.d.ts +151 -0
  110. package/dist/letta/subprocessThrottle.d.ts.map +1 -0
  111. package/dist/letta/subprocessThrottle.js +195 -0
  112. package/dist/letta/subprocessThrottle.js.map +1 -0
  113. package/dist/letta/tpmRecovery.d.ts +202 -0
  114. package/dist/letta/tpmRecovery.d.ts.map +1 -0
  115. package/dist/letta/tpmRecovery.js +314 -0
  116. package/dist/letta/tpmRecovery.js.map +1 -0
  117. package/dist/letta/types.d.ts +54 -0
  118. package/dist/letta/types.d.ts.map +1 -0
  119. package/dist/letta/types.js +16 -0
  120. package/dist/letta/types.js.map +1 -0
  121. package/dist/observability/logger.d.ts +48 -0
  122. package/dist/observability/logger.d.ts.map +1 -0
  123. package/dist/observability/logger.js +81 -0
  124. package/dist/observability/logger.js.map +1 -0
  125. package/dist/observability/metrics.d.ts +53 -0
  126. package/dist/observability/metrics.d.ts.map +1 -0
  127. package/dist/observability/metrics.js +92 -0
  128. package/dist/observability/metrics.js.map +1 -0
  129. package/dist/routes/admin/agents.d.ts +63 -0
  130. package/dist/routes/admin/agents.d.ts.map +1 -0
  131. package/dist/routes/admin/agents.js +461 -0
  132. package/dist/routes/admin/agents.js.map +1 -0
  133. package/dist/routes/admin/rotatingMessages.d.ts +51 -0
  134. package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
  135. package/dist/routes/admin/rotatingMessages.js +292 -0
  136. package/dist/routes/admin/rotatingMessages.js.map +1 -0
  137. package/dist/routes/admin/sessions.d.ts +61 -0
  138. package/dist/routes/admin/sessions.d.ts.map +1 -0
  139. package/dist/routes/admin/sessions.js +232 -0
  140. package/dist/routes/admin/sessions.js.map +1 -0
  141. package/dist/routes/admin/settings.d.ts +110 -0
  142. package/dist/routes/admin/settings.d.ts.map +1 -0
  143. package/dist/routes/admin/settings.js +401 -0
  144. package/dist/routes/admin/settings.js.map +1 -0
  145. package/dist/routes/admin/users.d.ts +115 -0
  146. package/dist/routes/admin/users.d.ts.map +1 -0
  147. package/dist/routes/admin/users.js +374 -0
  148. package/dist/routes/admin/users.js.map +1 -0
  149. package/dist/routes/agents.d.ts +30 -0
  150. package/dist/routes/agents.d.ts.map +1 -0
  151. package/dist/routes/agents.js +55 -0
  152. package/dist/routes/agents.js.map +1 -0
  153. package/dist/routes/auth.d.ts +121 -0
  154. package/dist/routes/auth.d.ts.map +1 -0
  155. package/dist/routes/auth.js +422 -0
  156. package/dist/routes/auth.js.map +1 -0
  157. package/dist/routes/chat.d.ts +97 -0
  158. package/dist/routes/chat.d.ts.map +1 -0
  159. package/dist/routes/chat.js +484 -0
  160. package/dist/routes/chat.js.map +1 -0
  161. package/dist/routes/featureToggles.d.ts +34 -0
  162. package/dist/routes/featureToggles.d.ts.map +1 -0
  163. package/dist/routes/featureToggles.js +47 -0
  164. package/dist/routes/featureToggles.js.map +1 -0
  165. package/dist/routes/health.d.ts +24 -0
  166. package/dist/routes/health.d.ts.map +1 -0
  167. package/dist/routes/health.js +37 -0
  168. package/dist/routes/health.js.map +1 -0
  169. package/dist/routes/metrics.d.ts +35 -0
  170. package/dist/routes/metrics.d.ts.map +1 -0
  171. package/dist/routes/metrics.js +106 -0
  172. package/dist/routes/metrics.js.map +1 -0
  173. package/dist/routes/sessions.d.ts +161 -0
  174. package/dist/routes/sessions.d.ts.map +1 -0
  175. package/dist/routes/sessions.js +1501 -0
  176. package/dist/routes/sessions.js.map +1 -0
  177. package/dist/routes/stubs.d.ts +25 -0
  178. package/dist/routes/stubs.d.ts.map +1 -0
  179. package/dist/routes/stubs.js +25 -0
  180. package/dist/routes/stubs.js.map +1 -0
  181. package/dist/state/cron.d.ts +58 -0
  182. package/dist/state/cron.d.ts.map +1 -0
  183. package/dist/state/cron.js +160 -0
  184. package/dist/state/cron.js.map +1 -0
  185. package/dist/state/rotationRunner.d.ts +96 -0
  186. package/dist/state/rotationRunner.d.ts.map +1 -0
  187. package/dist/state/rotationRunner.js +246 -0
  188. package/dist/state/rotationRunner.js.map +1 -0
  189. package/dist/state/rotationScheduler.d.ts +76 -0
  190. package/dist/state/rotationScheduler.d.ts.map +1 -0
  191. package/dist/state/rotationScheduler.js +124 -0
  192. package/dist/state/rotationScheduler.js.map +1 -0
  193. package/dist/state/turnRegistry.d.ts +95 -0
  194. package/dist/state/turnRegistry.d.ts.map +1 -0
  195. package/dist/state/turnRegistry.js +149 -0
  196. package/dist/state/turnRegistry.js.map +1 -0
  197. package/dist/state/turnRunner.d.ts +134 -0
  198. package/dist/state/turnRunner.d.ts.map +1 -0
  199. package/dist/state/turnRunner.js +476 -0
  200. package/dist/state/turnRunner.js.map +1 -0
  201. package/package.json +47 -0
  202. package/src/auth/argon2.ts +152 -0
  203. package/src/auth/encryption.ts +110 -0
  204. package/src/auth/jwt.ts +217 -0
  205. package/src/auth/middleware.ts +250 -0
  206. package/src/auth/passwordPolicy.ts +99 -0
  207. package/src/auth/roleRules.ts +497 -0
  208. package/src/auth/userView.ts +88 -0
  209. package/src/bootstrap.ts +254 -0
  210. package/src/db/connection.ts +75 -0
  211. package/src/db/migrate.ts +212 -0
  212. package/src/db/schema.ts +750 -0
  213. package/src/db/seed.ts +236 -0
  214. package/src/handlers/fetch.ts +251 -0
  215. package/src/handlers/nextjs.ts +49 -0
  216. package/src/handlers/responses.ts +95 -0
  217. package/src/handlers/routeTable.ts +188 -0
  218. package/src/handlers/router.ts +96 -0
  219. package/src/handlers/sse.ts +55 -0
  220. package/src/handlers/types.ts +104 -0
  221. package/src/index.ts +60 -0
  222. package/src/letta/client.ts +860 -0
  223. package/src/letta/eventTranslator.ts +667 -0
  224. package/src/letta/index.ts +46 -0
  225. package/src/letta/lettaCliShim.mjs +40 -0
  226. package/src/letta/sandbox.ts +167 -0
  227. package/src/letta/settingsFileSeed.ts +254 -0
  228. package/src/letta/subprocessThrottle.ts +251 -0
  229. package/src/letta/types.ts +65 -0
  230. package/src/observability/logger.ts +103 -0
  231. package/src/observability/metrics.ts +114 -0
  232. package/src/routes/admin/agents.ts +574 -0
  233. package/src/routes/admin/rotatingMessages.ts +351 -0
  234. package/src/routes/admin/sessions.ts +283 -0
  235. package/src/routes/admin/settings.ts +463 -0
  236. package/src/routes/admin/users.ts +455 -0
  237. package/src/routes/agents.ts +66 -0
  238. package/src/routes/auth.ts +513 -0
  239. package/src/routes/chat.ts +534 -0
  240. package/src/routes/featureToggles.ts +52 -0
  241. package/src/routes/health.ts +44 -0
  242. package/src/routes/metrics.ts +111 -0
  243. package/src/routes/sessions.ts +1878 -0
  244. package/src/state/cron.ts +179 -0
  245. package/src/state/rotationRunner.ts +326 -0
  246. package/src/state/rotationScheduler.ts +164 -0
  247. package/src/state/turnRegistry.ts +210 -0
  248. package/src/state/turnRunner.ts +588 -0
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Public exports for the `letta/` module — the single entry point the rest
3
+ * of the server uses to reach Letta. Per §6.4, **every Letta interaction
4
+ * goes through `LettaClient`**; no raw HTTP, no other entry points.
5
+ *
6
+ * @see SPECIFICATIONS.md §6.4
7
+ */
8
+
9
+ export {
10
+ createDefaultLettaClient,
11
+ type CreateLettaClientDeps,
12
+ type LettaClient,
13
+ type StreamUserMessageOpts,
14
+ } from "./client.js";
15
+ export {
16
+ finalizeAssistantMessage,
17
+ finalizeTerminal,
18
+ initialTranslatorState,
19
+ translateSdkMessage,
20
+ type TranslateStep,
21
+ type TranslatorState,
22
+ type UnsequencedEvent,
23
+ } from "./eventTranslator.js";
24
+ export {
25
+ ServerOverloadedError,
26
+ SubprocessThrottle,
27
+ readThrottleConfigFromEnv,
28
+ type ThrottleConfig,
29
+ type ThrottleLease,
30
+ } from "./subprocessThrottle.js";
31
+ export {
32
+ seedAgentToolsetNone,
33
+ type SeedOutcome,
34
+ type ToolsetPreference,
35
+ } from "./settingsFileSeed.js";
36
+ export type {
37
+ LettaAssistantMessage,
38
+ LettaHiddenReasoningMessage,
39
+ LettaMessage,
40
+ LettaReasoningMessage,
41
+ LettaSdk,
42
+ LettaStreamEvent,
43
+ LettaToolCallMessage,
44
+ LettaToolReturnMessage,
45
+ LettaUserMessage,
46
+ } from "./types.js";
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Letta Code CLI shim — runs in front of the spawned CLI subprocess
4
+ * (via `LETTA_CLI_PATH`) and injects `--tools ""` into argv before
5
+ * loading the CLI bundle.
6
+ *
7
+ * **Why `--tools ""`.** The SDK doesn't expose the flag that actually
8
+ * empties the CLI's tool registry. It only exposes `--allowedTools` /
9
+ * `--disallowedTools`, which are runtime permission gates, not
10
+ * registry filters. The `--tools` flag is the only mechanism that
11
+ * makes the LLM see ONLY the agent's ADE-attached tools (equivalent
12
+ * to the TUI `/toolset none` action).
13
+ *
14
+ * **CLI resolution** — `require.resolve("@data-club/letta-code")` from
15
+ * node_modules. The DataClub fork is published to npmjs.org under the
16
+ * `@data-club` scope (forked from `@letta-ai/letta-code`); locally it's
17
+ * `link:`-overridden to `../letta-code-fork` via `pnpm.overrides`.
18
+ */
19
+
20
+ import { createRequire } from "node:module";
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const lettaCliPath = require.resolve("@data-club/letta-code");
24
+
25
+ // Rewrite argv to point at the real CLI + inject `--tools ""`. argv[0]
26
+ // (the node binary path) is preserved; argv[1] becomes the real CLI
27
+ // path so any path-relative resolution inside the CLI (e.g. finding
28
+ // the bundled `skills/` dir) works correctly.
29
+ process.argv = [
30
+ process.argv[0],
31
+ lettaCliPath,
32
+ "--tools",
33
+ "",
34
+ ...process.argv.slice(2),
35
+ ];
36
+
37
+ // Load the real CLI. The bundle's top-level code parses argv, sees
38
+ // our injected `--tools ""`, calls `toolFilter.setEnabledTools("")`,
39
+ // and starts up with an empty tool-registration allowlist.
40
+ await import(lettaCliPath);
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Per-session sandbox + seed directory helpers (Step 12.5).
3
+ *
4
+ * The "sandbox" is a per-session working directory under
5
+ * `{AI_HUB_FILES_DIR}/sandboxes/{session_id}/` that the Letta Code
6
+ * subprocess for that session runs with as its `cwd`. At session-create
7
+ * time the server recursively copies the per-agent seed folder
8
+ * (`{AI_HUB_FILES_DIR}/seeds/{name}/`) into the sandbox. The seed is the
9
+ * starting state; the sandbox is what the agent's tools mutate during
10
+ * chat turns.
11
+ *
12
+ * Security stance: filesystem-isolation convention, not a security
13
+ * boundary — see [docs/server/session-sandboxes.md](../../../docs/server/session-sandboxes.md).
14
+ *
15
+ * @see SPECIFICATIONS.md §6.4 — Letta integration
16
+ * @see docs/server/session-sandboxes.md
17
+ */
18
+
19
+ import { existsSync, mkdirSync } from "node:fs";
20
+ import { cp, rm } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+
23
+ /**
24
+ * Result of validating a `sandbox_seed_name` against the safe-name regex.
25
+ * The admin endpoint maps these to HTTP 400 codes.
26
+ */
27
+ export type SeedNameValidation = "ok" | "invalid_chars" | "empty";
28
+
29
+ /**
30
+ * Regex of allowed `sandbox_seed_name` characters. Restrictive on purpose:
31
+ * letters / digits / underscore / dash. No dots (rules out `..` traversal),
32
+ * no slashes (rules out absolute paths and subdir refs), no spaces.
33
+ */
34
+ const SAFE_SEED_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
35
+
36
+ /**
37
+ * Validates a candidate seed name for the agent editor. Defense-in-depth —
38
+ * `resolveSeedDir` also enforces the same check before computing a path.
39
+ *
40
+ * @param name - raw value from the request body.
41
+ * @returns `"ok"` if it passes; otherwise the specific failure reason.
42
+ */
43
+ export function validateSeedName(name: string): SeedNameValidation {
44
+ if (name.length === 0) return "empty";
45
+ if (!SAFE_SEED_NAME_REGEX.test(name)) return "invalid_chars";
46
+ return "ok";
47
+ }
48
+
49
+ /**
50
+ * Resolves the absolute base files directory (the parent of `seeds/` and
51
+ * `sandboxes/`). Reads `AI_HUB_FILES_DIR` from `env`; falls back to
52
+ * `{AI_HUB_DATA_DIR}/files` and then `./data/files`. Creates the directory
53
+ * tree (`<base>/seeds`, `<base>/sandboxes`) if missing — mkdir-recursive
54
+ * is idempotent, so the boot path can call this freely.
55
+ *
56
+ * @param env - env-var record (`process.env` in production, synthesized in
57
+ * tests).
58
+ * @returns the resolved absolute path of the base files directory.
59
+ */
60
+ export function resolveFilesDir(
61
+ env: Record<string, string | undefined>,
62
+ ): string {
63
+ const explicit = env.AI_HUB_FILES_DIR;
64
+ if (explicit && explicit.trim().length > 0) {
65
+ const base = explicit.trim();
66
+ mkdirSync(join(base, "seeds"), { recursive: true });
67
+ mkdirSync(join(base, "sandboxes"), { recursive: true });
68
+ return base;
69
+ }
70
+ const dataDir = env.AI_HUB_DATA_DIR?.trim() || "./data";
71
+ const base = join(dataDir, "files");
72
+ mkdirSync(join(base, "seeds"), { recursive: true });
73
+ mkdirSync(join(base, "sandboxes"), { recursive: true });
74
+ return base;
75
+ }
76
+
77
+ /**
78
+ * Resolves the absolute path of a per-session sandbox folder.
79
+ *
80
+ * @param filesDir - the base files directory from `resolveFilesDir`.
81
+ * @param sessionId - the ai-hub session id.
82
+ * @returns the absolute path (no I/O; caller decides whether to copy
83
+ * into it or `rm`).
84
+ */
85
+ export function resolveSandboxDir(filesDir: string, sessionId: string): string {
86
+ // Session ids are server-generated UUIDs (see randomUUID in
87
+ // routes/sessions.ts); they cannot contain slashes or path separators.
88
+ // The join is defensive against future id-shape changes.
89
+ return join(filesDir, "sandboxes", sessionId);
90
+ }
91
+
92
+ /**
93
+ * Resolves the absolute path of a seed folder by name. Validates the name
94
+ * defensively — production callers should have already called
95
+ * `validateSeedName` and reported a 400 to the user, so reaching the
96
+ * invalid-name branch here would be a programmer error.
97
+ *
98
+ * @param filesDir - the base files directory from `resolveFilesDir`.
99
+ * @param seedName - the operator-supplied seed-folder name.
100
+ * @returns the absolute path.
101
+ * @throws when `seedName` fails the safe-name regex.
102
+ */
103
+ export function resolveSeedDir(filesDir: string, seedName: string): string {
104
+ if (validateSeedName(seedName) !== "ok") {
105
+ throw new Error(`Invalid seed name: ${seedName}`);
106
+ }
107
+ return join(filesDir, "seeds", seedName);
108
+ }
109
+
110
+ /**
111
+ * Recursively copies the seed folder into the (not-yet-existing) sandbox
112
+ * target. The target's parent is created via `mkdir -p` if needed; the
113
+ * target itself is created by `cp({ recursive: true })`. Throws on any
114
+ * I/O failure — the caller is responsible for rolling back the just-
115
+ * created Letta conversation if this throws.
116
+ *
117
+ * Symlinks in the seed are followed (`{ verbatimSymlinks: false }` is
118
+ * Node's default). Permissions are preserved. The implementation uses
119
+ * `fs.promises.cp` so it's a single syscall sequence per file with no
120
+ * intermediate I/O on our side.
121
+ *
122
+ * @param seedDir - source folder (must exist).
123
+ * @param targetDir - destination folder (must NOT exist).
124
+ */
125
+ export async function materializeSandbox(
126
+ seedDir: string,
127
+ targetDir: string,
128
+ ): Promise<void> {
129
+ if (!existsSync(seedDir)) {
130
+ throw new Error(`Seed folder does not exist on disk: ${seedDir}`);
131
+ }
132
+ // `recursive: true` + `errorOnExist: false` matches `cp -R` semantics —
133
+ // if `targetDir` somehow already exists (impossible in the happy path
134
+ // since session ids are fresh UUIDs, but cheap to be safe), the
135
+ // copy lands the seed contents on top of whatever was there. We do
136
+ // NOT pass `force: false` (which would refuse on conflicts) because
137
+ // an existing sandbox in our state machine should never block a new
138
+ // session.
139
+ await cp(seedDir, targetDir, { recursive: true, errorOnExist: false });
140
+ }
141
+
142
+ /**
143
+ * Removes a sandbox folder and all its contents. Idempotent: a no-op when
144
+ * the folder doesn't exist. Used by `POST /admin/sessions/:id/purge-sandbox`
145
+ * and by the rollback path in `POST /sessions` when something downstream
146
+ * of materialization throws.
147
+ *
148
+ * @param targetDir - absolute path of the sandbox folder.
149
+ */
150
+ export async function purgeSandbox(targetDir: string): Promise<void> {
151
+ await rm(targetDir, { recursive: true, force: true });
152
+ }
153
+
154
+ /**
155
+ * Checks whether a seed folder is present on disk. Used by the admin
156
+ * `POST` / `PATCH /admin/agents` validators to surface the 400 inline
157
+ * before storing a name that wouldn't resolve at session-create time.
158
+ *
159
+ * @param filesDir - base files dir from `resolveFilesDir`.
160
+ * @param seedName - the operator-supplied name (must already pass
161
+ * `validateSeedName`).
162
+ * @returns `true` if `<filesDir>/seeds/<seedName>` exists.
163
+ */
164
+ export function seedDirExists(filesDir: string, seedName: string): boolean {
165
+ if (validateSeedName(seedName) !== "ok") return false;
166
+ return existsSync(resolveSeedDir(filesDir, seedName));
167
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Seeds Letta Code's global agent-settings file (`~/.letta/settings.json`)
3
+ * with `{toolset: "none"}` for each ai-hub-registered Letta agent.
4
+ *
5
+ * **Why this matters.** Letta Code's bundled CLI auto-attaches a "toolset"
6
+ * (a set of file-edit / shell / search tools — `Read`, `Write`, `Edit`,
7
+ * `Bash`, etc.) to every agent at session start, based on the model handle.
8
+ * ai-hub doesn't want those tools — we want only the tools the ADE admin
9
+ * explicitly attached to the agent. The CLI honors a per-agent
10
+ * `toolset: "none"` preference stored in its global settings file, which
11
+ * suppresses the entire auto-toolset.
12
+ *
13
+ * **Why global, not project-local.** Letta Code's `SettingsManager` reads
14
+ * the toolset preference from `~/.letta/settings.json` (the global file),
15
+ * not `<cwd>/.letta/settings.local.json` (the project file). The global
16
+ * settings carry agent records keyed by `(agentId, normalizedBaseUrl)`
17
+ * where `normalizedBaseUrl` is the value of `LETTA_BASE_URL` with the
18
+ * scheme and trailing slash stripped, or `undefined` when the agent lives
19
+ * on Letta Cloud. Self-hosted Letta deployments always have a non-default
20
+ * `LETTA_BASE_URL` so the agent record always has the `baseUrl` field set.
21
+ *
22
+ * The `LETTA_CLI_PATH` shim (which injects `--tools ""`) is the primary
23
+ * mechanism that forces an empty tool registry; this settings-file seed
24
+ * is the defense-in-depth fallback the TUI also honors.
25
+ */
26
+
27
+ import {
28
+ existsSync,
29
+ mkdirSync,
30
+ readFileSync,
31
+ renameSync,
32
+ writeFileSync,
33
+ } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { dirname, join } from "node:path";
36
+
37
+ import type { Logger } from "../observability/logger.js";
38
+
39
+ /* ────────────────────────────────────────────────────────────────────────── */
40
+ /* Settings file shape */
41
+ /* ────────────────────────────────────────────────────────────────────────── */
42
+
43
+ /**
44
+ * Toolset preference values accepted by Letta Code. `"none"` is the
45
+ * documented-but-not-help-listed value that suppresses every auto-attached
46
+ * tool; the named toolsets (`codex`, `gemini`, `default`) attach specific
47
+ * sets of file/shell tools that ai-hub doesn't want.
48
+ *
49
+ * The string is treated as an opaque scalar by Letta Code's persistence
50
+ * layer; we still narrow the TypeScript type here so a typo in this file
51
+ * fails at compile time.
52
+ */
53
+ export type ToolsetPreference = "none" | "auto" | "codex" | "codex_snake" | "gemini" | "gemini_snake" | "default";
54
+
55
+ /** One agent entry inside the global settings file. Mirrors Letta Code's
56
+ * internal shape so our writes round-trip with its own writes. Optional
57
+ * fields are intentionally absent when unset, matching Letta Code's
58
+ * delete-when-falsy convention. */
59
+ export interface AgentSettingsEntry {
60
+ agentId: string;
61
+ /** Normalized base URL ("localhost:8283/v1" style — no scheme, no
62
+ * trailing slash). Omitted for Letta Cloud agents. */
63
+ baseUrl?: string;
64
+ pinned?: boolean;
65
+ memfs?: boolean;
66
+ toolset?: ToolsetPreference;
67
+ systemPromptPreset?: string;
68
+ }
69
+
70
+ /** Shape of `~/.letta/settings.json` that we touch. We pass through every
71
+ * unknown top-level field unchanged so concurrent Letta Code edits don't
72
+ * lose data. */
73
+ export interface LettaCodeSettingsFile {
74
+ agents?: AgentSettingsEntry[];
75
+ /** Any other fields Letta Code writes (oauth state, env vars, …). We
76
+ * treat them as opaque on read + write. */
77
+ [other: string]: unknown;
78
+ }
79
+
80
+ /* ────────────────────────────────────────────────────────────────────────── */
81
+ /* Path resolution */
82
+ /* ────────────────────────────────────────────────────────────────────────── */
83
+
84
+ /**
85
+ * Returns the absolute path to Letta Code's global settings file. Tests
86
+ * override `HOME` to point at a tmp dir.
87
+ *
88
+ * @returns the path. (Doesn't check existence — caller does.)
89
+ */
90
+ export function resolveGlobalSettingsPath(): string {
91
+ return join(homedir(), ".letta", "settings.json");
92
+ }
93
+
94
+ /**
95
+ * Normalizes `LETTA_BASE_URL` to match the key Letta Code's CLI uses to
96
+ * look up agent settings AFTER ai-hub-server's `bootstrap.ts` runs its
97
+ * own `/v1` stripping. The CLI itself only strips scheme + trailing
98
+ * slash (so `http://host:p/v1` → `host:p/v1`), but bootstrap.ts mutates
99
+ * `process.env.LETTA_BASE_URL` at boot to drop the `/v1` (because the
100
+ * Letta Client SDK that the CLI uses internally appends `/v1` itself —
101
+ * leaving it in the env produces double-`/v1` requests that 404).
102
+ *
103
+ * So by the time the spawned CLI looks up the agent settings, its
104
+ * `getCurrentServerKey` reads `http://host:p` from env and produces
105
+ * `host:p`. The settings file entry must use the same key — hence the
106
+ * extra `/v1` strip here. If `bootstrap.ts` changes its policy, this
107
+ * helper has to change in lockstep.
108
+ *
109
+ * Returns `undefined` for the Letta Cloud default URL (so the agent
110
+ * record omits the `baseUrl` field, matching Letta Code's behavior on
111
+ * Cloud).
112
+ *
113
+ * @param baseUrl - raw `LETTA_BASE_URL` value.
114
+ * @returns the normalized form, or `undefined` for Letta Cloud.
115
+ */
116
+ export function normalizeLettaBaseUrl(baseUrl: string | undefined): string | undefined {
117
+ if (!baseUrl) return undefined;
118
+ const stripped = baseUrl
119
+ .replace(/^https?:\/\//, "")
120
+ .replace(/\/v1\/?$/i, "")
121
+ .replace(/\/$/, "");
122
+ if (stripped === "api.letta.com") return undefined;
123
+ return stripped;
124
+ }
125
+
126
+ /* ────────────────────────────────────────────────────────────────────────── */
127
+ /* Read + write */
128
+ /* ────────────────────────────────────────────────────────────────────────── */
129
+
130
+ /**
131
+ * Reads the settings file from disk. Returns an empty object if the file
132
+ * doesn't exist or is unparseable — both cases are recoverable by an
133
+ * idempotent upsert that recreates the file.
134
+ *
135
+ * @param path - the resolved settings path.
136
+ * @returns the parsed settings (or `{}`).
137
+ */
138
+ export function readSettingsFile(path: string): LettaCodeSettingsFile {
139
+ if (!existsSync(path)) return {};
140
+ try {
141
+ const raw = readFileSync(path, "utf8");
142
+ if (!raw.trim()) return {};
143
+ const parsed = JSON.parse(raw);
144
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
145
+ return parsed as LettaCodeSettingsFile;
146
+ }
147
+ return {};
148
+ } catch {
149
+ // Malformed file (concurrent write, disk corruption) — treat as empty
150
+ // so the seed proceeds. Worst case the operator's other settings (if
151
+ // any) are clobbered, which is acceptable when the file was already
152
+ // broken.
153
+ return {};
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Writes the settings file atomically: write to `<path>.tmp` then rename.
159
+ * The rename is atomic on every filesystem the ai-hub-server supports
160
+ * (ext4, xfs, btrfs, apfs). Creates `~/.letta/` if missing.
161
+ *
162
+ * @param path - the resolved settings path.
163
+ * @param settings - the new contents.
164
+ */
165
+ export function writeSettingsFile(path: string, settings: LettaCodeSettingsFile): void {
166
+ mkdirSync(dirname(path), { recursive: true });
167
+ const tmp = `${path}.tmp`;
168
+ writeFileSync(tmp, JSON.stringify(settings, null, 2), { encoding: "utf8", mode: 0o600 });
169
+ renameSync(tmp, path);
170
+ }
171
+
172
+ /* ────────────────────────────────────────────────────────────────────────── */
173
+ /* Upsert */
174
+ /* ────────────────────────────────────────────────────────────────────────── */
175
+
176
+ /**
177
+ * Outcome of a single seed call. Useful for tests and observability.
178
+ */
179
+ export type SeedOutcome =
180
+ | { kind: "created"; previousToolset: undefined }
181
+ | { kind: "updated"; previousToolset: ToolsetPreference | undefined }
182
+ | { kind: "noop"; previousToolset: ToolsetPreference };
183
+
184
+ /**
185
+ * Idempotent upsert of `{toolset: "none"}` for a single agent in the
186
+ * global settings file.
187
+ *
188
+ * Matching is by `(agentId, baseUrl)` — same composite key Letta Code
189
+ * uses. If the agent record exists with a different toolset (`"auto"`,
190
+ * `"codex"`, etc.), it's replaced. If it exists already with `"none"`,
191
+ * we no-op (don't churn the file's mtime).
192
+ *
193
+ * @param opts.agentId - the Letta agent id (e.g. `agent-xxx`).
194
+ * @param opts.lettaBaseUrl - the raw `LETTA_BASE_URL` value (so the
195
+ * helper can normalize it the same way Letta Code does). Pass
196
+ * `process.env.LETTA_BASE_URL` from the caller.
197
+ * @param opts.settingsPath - override the settings-file path. Defaults to
198
+ * `~/.letta/settings.json`. Tests inject a tmp path.
199
+ * @param opts.logger - optional pino logger for observability lines.
200
+ * @returns the outcome (created / updated / noop) so the caller can log.
201
+ */
202
+ export function seedAgentToolsetNone(opts: {
203
+ agentId: string;
204
+ lettaBaseUrl: string | undefined;
205
+ settingsPath?: string;
206
+ logger?: Logger;
207
+ }): SeedOutcome {
208
+ const path = opts.settingsPath ?? resolveGlobalSettingsPath();
209
+ const normalizedBaseUrl = normalizeLettaBaseUrl(opts.lettaBaseUrl);
210
+ const settings = readSettingsFile(path);
211
+ const agents: AgentSettingsEntry[] = Array.isArray(settings.agents) ? [...settings.agents] : [];
212
+
213
+ const matchIdx = agents.findIndex(
214
+ (a) => a.agentId === opts.agentId && (a.baseUrl ?? undefined) === normalizedBaseUrl,
215
+ );
216
+
217
+ let outcome: SeedOutcome;
218
+
219
+ if (matchIdx >= 0) {
220
+ const existing = agents[matchIdx]!;
221
+ const prev = existing.toolset;
222
+ if (prev === "none") {
223
+ outcome = { kind: "noop", previousToolset: "none" };
224
+ opts.logger?.debug({ agentId: opts.agentId, baseUrl: normalizedBaseUrl }, "letta_code_settings_seed_noop");
225
+ return outcome;
226
+ }
227
+ agents[matchIdx] = { ...existing, toolset: "none" };
228
+ outcome = { kind: "updated", previousToolset: prev };
229
+ } else {
230
+ const entry: AgentSettingsEntry = {
231
+ agentId: opts.agentId,
232
+ toolset: "none",
233
+ };
234
+ if (normalizedBaseUrl !== undefined) entry.baseUrl = normalizedBaseUrl;
235
+ agents.push(entry);
236
+ outcome = { kind: "created", previousToolset: undefined };
237
+ }
238
+
239
+ const next: LettaCodeSettingsFile = { ...settings, agents };
240
+ writeSettingsFile(path, next);
241
+
242
+ opts.logger?.info(
243
+ {
244
+ agentId: opts.agentId,
245
+ baseUrl: normalizedBaseUrl,
246
+ outcome: outcome.kind,
247
+ previousToolset: outcome.previousToolset,
248
+ settingsPath: path,
249
+ },
250
+ "letta_code_settings_seed",
251
+ );
252
+
253
+ return outcome;
254
+ }