@vellumai/credential-executor 0.10.7 → 0.10.8-dev.202607102228.5945895

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 (65) hide show
  1. package/Dockerfile +1 -1
  2. package/node_modules/@vellumai/service-contracts/package.json +1 -2
  3. package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
  4. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
  5. package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
  6. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
  7. package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
  8. package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
  9. package/package.json +2 -3
  10. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  11. package/src/__tests__/local-standalone.test.ts +5 -36
  12. package/src/__tests__/managed-integration.test.ts +112 -91
  13. package/src/__tests__/managed-reconnect.test.ts +2 -2
  14. package/src/__tests__/transport.test.ts +23 -27
  15. package/src/cli.ts +1 -1
  16. package/src/index.ts +8 -88
  17. package/src/main.ts +228 -340
  18. package/src/paths.ts +4 -20
  19. package/src/server.ts +52 -469
  20. package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
  21. package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
  22. package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
  23. package/src/__tests__/command-executor.test.ts +0 -1879
  24. package/src/__tests__/command-validator.test.ts +0 -1405
  25. package/src/__tests__/command-workspace.test.ts +0 -1050
  26. package/src/__tests__/grant-store.test.ts +0 -689
  27. package/src/__tests__/http-executor.test.ts +0 -1336
  28. package/src/__tests__/http-policy.test.ts +0 -1069
  29. package/src/__tests__/local-materializers.test.ts +0 -860
  30. package/src/__tests__/local-token-refresh.test.ts +0 -361
  31. package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
  32. package/src/__tests__/managed-lazy-getters.test.ts +0 -359
  33. package/src/__tests__/managed-materializers.test.ts +0 -1028
  34. package/src/__tests__/managed-rejection.test.ts +0 -43
  35. package/src/__tests__/toolstore.test.ts +0 -773
  36. package/src/audit/store.ts +0 -188
  37. package/src/commands/auth-adapters.ts +0 -169
  38. package/src/commands/egress-hooks.ts +0 -203
  39. package/src/commands/executor.ts +0 -1155
  40. package/src/commands/output-scan.ts +0 -157
  41. package/src/commands/profiles.ts +0 -286
  42. package/src/commands/validator.ts +0 -702
  43. package/src/commands/workspace.ts +0 -550
  44. package/src/grants/index.ts +0 -17
  45. package/src/grants/persistent-store.ts +0 -309
  46. package/src/grants/rpc-handlers.ts +0 -293
  47. package/src/grants/temporary-store.ts +0 -289
  48. package/src/http/audit.ts +0 -84
  49. package/src/http/executor.ts +0 -684
  50. package/src/http/path-template.ts +0 -245
  51. package/src/http/policy.ts +0 -238
  52. package/src/http/response-filter.ts +0 -233
  53. package/src/managed-errors.ts +0 -9
  54. package/src/managed-lazy-getters.ts +0 -106
  55. package/src/managed-main.ts +0 -822
  56. package/src/materializers/local-oauth-lookup.ts +0 -98
  57. package/src/materializers/local-token-refresh.ts +0 -287
  58. package/src/materializers/local.ts +0 -316
  59. package/src/materializers/managed-platform.ts +0 -295
  60. package/src/subjects/local.ts +0 -177
  61. package/src/subjects/managed.ts +0 -311
  62. package/src/subjects/policy.ts +0 -79
  63. package/src/toolstore/integrity.ts +0 -94
  64. package/src/toolstore/manifest.ts +0 -154
  65. package/src/toolstore/publish.ts +0 -571
package/src/main.ts CHANGED
@@ -1,26 +1,24 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Local CES entrypoint. Two run modes:
3
+ * CES (Credential Execution Service) entrypoint unified for both
4
+ * local (bare-metal sibling) and managed (Kubernetes sidecar) modes.
4
5
  *
5
- * - **stdio child (default):** the assistant spawns CES as a child process and
6
- * communicates over stdin/stdout. CES shuts down when stdin closes (parent
7
- * exit) or on SIGTERM. This is today's behavior.
6
+ * Mode is determined by `getCesMode()` from `paths.ts`:
7
+ * - `CES_MODE=managed` (set in the Dockerfile / K8s statefulset) → managed mode
8
+ * - absent or any other value local mode (bare-metal sibling)
8
9
  *
9
- * - **standalone sibling (`CES_STANDALONE=1`):** CES is launched independently
10
- * by the CLI (the opt-in), serves RPC over a
11
- * Unix socket (`getLocalSocketPath()`), and runs until SIGTERM no stdio.
12
- * This is the direction local CES is converging on; the socket-serving here
13
- * is temporary scaffolding to be folded into a single unified CES entrypoint.
10
+ * Both modes serve credential CRUD RPC over a Unix socket using a concurrent
11
+ * multi-connection server (`serveStandaloneSocket`). Each connection gets its
12
+ * own `CesRpcServer` over a shared, process-scoped handler registry. The server
13
+ * stays listening across connections, so an assistant that disconnects (crash,
14
+ * restart) can reconnect without CES re-binding.
14
15
  *
15
- * Local mode never opens a TCP listener. Neither the stdio transport nor the
16
- * Unix socket's listening fd is inherited by shell subprocesses spawned by CES
17
- * (e.g. for `run_authenticated_command`): Bun's `Bun.spawn` defaults to "pipe"
18
- * for stdio, and the listening socket is not passed to those subprocesses.
16
+ * Managed mode additionally starts a health HTTP server (`/healthz`, `/readyz`,
17
+ * optional credential CRUD routes) for Kubernetes liveness/readiness probes.
19
18
  */
20
19
 
21
20
  import { mkdirSync, unlinkSync } from "node:fs";
22
21
  import { createServer as createNetServer, type Socket } from "node:net";
23
- import { homedir } from "node:os";
24
22
  import { dirname, join } from "node:path";
25
23
  import { Readable, Writable } from "node:stream";
26
24
 
@@ -28,285 +26,60 @@ import {
28
26
  CES_PROTOCOL_VERSION,
29
27
  CesRpcMethod,
30
28
  } from "@vellumai/service-contracts/credential-rpc";
31
- import { StaticCredentialMetadataStore } from "@vellumai/credential-storage";
32
-
33
- import { AuditStore } from "./audit/store.js";
34
- import { PersistentGrantStore } from "./grants/persistent-store.js";
35
- import {
36
- createListAuditRecordsHandler,
37
- createListGrantsHandler,
38
- createRecordGrantHandler,
39
- createRevokeGrantHandler,
40
- } from "./grants/rpc-handlers.js";
41
- import { TemporaryGrantStore } from "./grants/temporary-store.js";
42
- import { LocalMaterialiser } from "./materializers/local.js";
43
29
  import type { SecureKeyBackend } from "@vellumai/credential-storage";
30
+
44
31
  import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
45
- import { createLocalOAuthLookup } from "./materializers/local-oauth-lookup.js";
46
- import { createLocalTokenRefreshFn } from "./materializers/local-token-refresh.js";
47
- import { resolveLocalSubject } from "./subjects/local.js";
48
- import { checkCredentialPolicy } from "./subjects/policy.js";
49
32
  import { initLogger, getLogger } from "./logger.js";
50
33
  import {
51
- getCesAuditDir,
34
+ getBootstrapSocketPath,
52
35
  getCesDataRoot,
53
- getCesGrantsDir,
54
36
  getCesLogDir,
55
- getCesToolStoreDir,
37
+ getCesMode,
38
+ getHealthPort,
56
39
  getLocalSocketPath,
40
+ getSecurityDir,
41
+ type CesMode,
57
42
  } from "./paths.js";
43
+ import { CesRpcServer, type RpcHandlerRegistry } from "./server.js";
58
44
  import {
59
- buildHandlersWithHttp,
60
- CesRpcServer,
61
- registerCommandExecutionHandler,
62
- registerManageSecureCommandToolHandler,
63
- type RpcHandlerRegistry,
64
- } from "./server.js";
65
- import {
66
- deleteBundleFromToolstore,
67
- publishBundle,
68
- } from "./toolstore/publish.js";
69
- import { validateSourceUrl } from "./toolstore/manifest.js";
70
- import { buildCesEgressHooks } from "./commands/egress-hooks.js";
45
+ handleCredentialRoute,
46
+ type CredentialRouteDeps,
47
+ } from "./http/credential-routes.js";
48
+ import { handleLogExportRoute } from "./http/log-export-routes.js";
71
49
  import { CES_MIGRATIONS } from "./migrations/registry.js";
72
50
  import { runCesMigrations } from "./migrations/runner.js";
73
51
 
74
52
  // ---------------------------------------------------------------------------
75
- // Data directory bootstrap
53
+ // Logging (module-level for early bootstrap + structured logging post-init)
76
54
  // ---------------------------------------------------------------------------
77
55
 
78
- function ensureDataDirs(): void {
79
- const dirs = [
80
- getCesDataRoot("local"),
81
- getCesGrantsDir("local"),
82
- getCesAuditDir("local"),
83
- getCesToolStoreDir("local"),
84
- ];
85
- for (const dir of dirs) {
86
- mkdirSync(dir, { recursive: true });
87
- }
88
- }
56
+ const log = getLogger("main");
89
57
 
90
58
  // ---------------------------------------------------------------------------
91
- // Path resolution
59
+ // Data directory bootstrap
92
60
  // ---------------------------------------------------------------------------
93
61
 
94
- /**
95
- * Resolve the workspace directory.
96
- *
97
- * Priority:
98
- * 1. `VELLUM_WORKSPACE_DIR` env var (set by the platform template)
99
- * 2. Default: `~/.vellum/workspace`
100
- */
101
- function getWorkspaceDir(): string {
102
- return (
103
- process.env["VELLUM_WORKSPACE_DIR"]?.trim() ||
104
- join(homedir(), ".vellum", "workspace")
105
- );
106
- }
107
-
108
- /**
109
- * Resolve the CES security directory (contains key stores, encryption data).
110
- *
111
- * Priority:
112
- * 1. `CREDENTIAL_SECURITY_DIR` env var (set by the platform template for
113
- * the CES container — `/ces-security` in managed mode)
114
- * 2. Default: `~/.vellum/protected` (local mode)
115
- */
116
- function getSecurityDir(): string {
117
- return (
118
- process.env["CREDENTIAL_SECURITY_DIR"]?.trim() ||
119
- join(homedir(), ".vellum", "protected")
120
- );
62
+ function ensureDataDirs(mode: CesMode): void {
63
+ mkdirSync(getCesDataRoot(mode), { recursive: true });
121
64
  }
122
65
 
123
66
  // ---------------------------------------------------------------------------
124
- // Build RPC handler registry
67
+ // Credential CRUD handlers
125
68
  // ---------------------------------------------------------------------------
126
69
 
127
- function buildHandlers(secureKeyBackend: SecureKeyBackend): RpcHandlerRegistry {
128
- // -- Grant stores ----------------------------------------------------------
129
- const persistentGrantStore = new PersistentGrantStore(
130
- getCesGrantsDir("local"),
131
- );
132
- persistentGrantStore.init();
133
-
134
- const temporaryGrantStore = new TemporaryGrantStore();
135
-
136
- // -- Audit store -----------------------------------------------------------
137
- const auditStore = new AuditStore(getCesAuditDir("local"));
138
- auditStore.init();
139
-
140
- // -- Credential backend (local) --------------------------------------------
141
- // In local mode CES shares the filesystem with the assistant and can access
142
- // the same credential metadata and secure-key stores.
143
- const workspaceDir = getWorkspaceDir();
144
- const credentialMetadataPath = join(
145
- workspaceDir,
146
- "data",
147
- "credentials",
148
- "metadata.json",
149
- );
150
- const metadataStore = new StaticCredentialMetadataStore(
151
- credentialMetadataPath,
152
- );
153
-
154
- // Read-only OAuth connection lookup backed by the assistant's SQLite
155
- // database. CES opens the database in read-only mode.
156
- const oauthConnections = createLocalOAuthLookup(workspaceDir);
157
-
158
- const localMaterialiser = new LocalMaterialiser({
159
- secureKeyBackend,
160
- tokenRefreshFn: createLocalTokenRefreshFn(workspaceDir, secureKeyBackend),
161
- });
162
-
163
- // -- Build handler registry ------------------------------------------------
164
-
165
- // Start with the HTTP handler (make_authenticated_request)
166
- const handlers = buildHandlersWithHttp({
167
- persistentGrantStore,
168
- temporaryGrantStore,
169
- localMaterialiser,
170
- localSubjectDeps: {
171
- metadataStore,
172
- oauthConnections,
173
- },
174
- auditStore,
175
- });
176
-
177
- // Register run_authenticated_command handler
178
- registerCommandExecutionHandler(handlers, {
179
- executorDeps: {
180
- persistentStore: persistentGrantStore,
181
- temporaryStore: temporaryGrantStore,
182
- materializeCredential: async (handle) => {
183
- // Resolve the subject first, then materialise through the local backend
184
- const subjectResult = resolveLocalSubject(handle, {
185
- metadataStore,
186
- oauthConnections,
187
- });
188
- if (!subjectResult.ok) {
189
- return { ok: false as const, error: subjectResult.error };
190
- }
191
-
192
- // Enforce credential-level policies for local static handles
193
- if (subjectResult.subject.type === "local_static") {
194
- const policyCheck = checkCredentialPolicy(
195
- subjectResult.subject.metadata,
196
- "run_authenticated_command",
197
- );
198
- if (!policyCheck.ok) {
199
- return { ok: false as const, error: policyCheck.error! };
200
- }
201
- }
202
-
203
- const matResult = await localMaterialiser.materialise(
204
- subjectResult.subject,
205
- );
206
- if (!matResult.ok) {
207
- return { ok: false as const, error: matResult.error };
208
- }
209
- return {
210
- ok: true as const,
211
- value: matResult.credential.value,
212
- handleType: matResult.credential.handleType,
213
- };
214
- },
215
- auditStore,
216
- cesMode: "local",
217
- egressHooks: buildCesEgressHooks(),
218
- },
219
- defaultWorkspaceDir: workspaceDir,
220
- });
221
-
222
- // Register manage_secure_command_tool handler
223
- const toolRegistry = new Map<
224
- string,
225
- {
226
- toolName: string;
227
- credentialHandle: string;
228
- description: string;
229
- bundleDigest: string;
230
- }
231
- >();
232
-
233
- registerManageSecureCommandToolHandler(handlers, {
234
- downloadBundle: async (sourceUrl: string) => {
235
- const urlError = validateSourceUrl(sourceUrl);
236
- if (urlError) {
237
- throw new Error(urlError);
238
- }
239
- const MAX_BUNDLE_SIZE = 100 * 1024 * 1024; // 100 MB
240
- const resp = await fetch(sourceUrl, {
241
- signal: AbortSignal.timeout(60_000),
242
- });
243
- if (!resp.ok) {
244
- throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
245
- }
246
- const contentLength = resp.headers.get("content-length");
247
- if (contentLength && parseInt(contentLength, 10) > MAX_BUNDLE_SIZE) {
248
- throw new Error(
249
- `Bundle too large: ${contentLength} bytes (max ${MAX_BUNDLE_SIZE})`,
250
- );
251
- }
252
- // Stream the body and enforce the size limit on actual bytes received,
253
- // since Content-Length can be absent (chunked encoding) or lie.
254
- const body = resp.body;
255
- if (!body) {
256
- throw new Error("Response body is null");
257
- }
258
- const chunks: Uint8Array[] = [];
259
- let totalBytes = 0;
260
- for await (const chunk of body) {
261
- totalBytes += chunk.byteLength;
262
- if (totalBytes > MAX_BUNDLE_SIZE) {
263
- throw new Error(
264
- `Bundle too large: received >${MAX_BUNDLE_SIZE} bytes (max ${MAX_BUNDLE_SIZE})`,
265
- );
266
- }
267
- chunks.push(chunk);
268
- }
269
- return Buffer.concat(chunks);
270
- },
271
- publishBundle: (request) => publishBundle({ ...request, cesMode: "local" }),
272
- unregisterTool: (toolName: string) => {
273
- const entry = toolRegistry.get(toolName);
274
- const removed = toolRegistry.delete(toolName);
275
- if (removed && entry?.bundleDigest) {
276
- const stillInUse = Array.from(toolRegistry.values()).some(
277
- (t) => t.bundleDigest === entry.bundleDigest,
278
- );
279
- if (!stillInUse) {
280
- deleteBundleFromToolstore(entry.bundleDigest, "local");
281
- }
282
- }
283
- return removed;
284
- },
285
- registerTool: (entry) => {
286
- toolRegistry.set(entry.toolName, entry);
287
- },
288
- });
289
-
290
- // Register grant management handlers
291
- handlers[CesRpcMethod.RecordGrant] = createRecordGrantHandler({
292
- persistentGrantStore,
293
- temporaryGrantStore,
294
- }) as (typeof handlers)[string];
295
-
296
- handlers[CesRpcMethod.ListGrants] = createListGrantsHandler({
297
- persistentGrantStore,
298
- }) as (typeof handlers)[string];
299
-
300
- handlers[CesRpcMethod.RevokeGrant] = createRevokeGrantHandler({
301
- persistentGrantStore,
302
- }) as (typeof handlers)[string];
303
-
304
- // Register audit record handler
305
- handlers[CesRpcMethod.ListAuditRecords] = createListAuditRecordsHandler({
306
- auditStore,
307
- }) as (typeof handlers)[string];
70
+ /**
71
+ * Build the RPC handler registry. CES serves credential CRUD (get / set /
72
+ * delete / list / bulk-set) backed by the secure key store. The
73
+ * `update_managed_credential` handler is registered separately by the RPC
74
+ * server when an `onApiKeyUpdate` callback is supplied. Local and managed modes
75
+ * share the same registry — they differ only in where the secure key backend
76
+ * reads from and whether the health server is started.
77
+ */
78
+ function buildCrudHandlers(
79
+ secureKeyBackend: SecureKeyBackend,
80
+ ): RpcHandlerRegistry {
81
+ const handlers: RpcHandlerRegistry = {};
308
82
 
309
- // Register credential CRUD handlers
310
83
  handlers[CesRpcMethod.GetCredential] = (async (req: { account: string }) => {
311
84
  const value = await secureKeyBackend.get(req.account);
312
85
  return { found: value !== undefined, value };
@@ -347,16 +120,16 @@ function buildHandlers(secureKeyBackend: SecureKeyBackend): RpcHandlerRegistry {
347
120
  }
348
121
 
349
122
  // ---------------------------------------------------------------------------
350
- // Main
123
+ // Socket server (unified multi-connection model)
351
124
  // ---------------------------------------------------------------------------
352
125
 
353
126
  /**
354
- * Serve RPC over a Unix socket for standalone-sibling mode.
127
+ * Serve RPC over a Unix socket using a concurrent multi-connection model.
355
128
  *
356
129
  * Binds the socket, accepts connections concurrently (each served by its own
357
130
  * CesRpcServer over the shared handler registry), and unlinks the socket when
358
- * the signal aborts. Temporary scaffolding this serving path will be folded
359
- * into a single unified CES entrypoint shared with the managed sidecar.
131
+ * the signal aborts. The listener stays open across connections so a client
132
+ * that disconnects can reconnect without CES re-binding.
360
133
  */
361
134
  function serveStandaloneSocket(opts: {
362
135
  socketPath: string;
@@ -364,8 +137,22 @@ function serveStandaloneSocket(opts: {
364
137
  signal: AbortSignal;
365
138
  logger: Pick<Console, "log" | "warn" | "error">;
366
139
  log: ReturnType<typeof getLogger>;
140
+ onHandshakeComplete?: (
141
+ sessionId: string,
142
+ assistantApiKey?: string,
143
+ assistantId?: string,
144
+ ) => void;
145
+ onApiKeyUpdate?: (assistantApiKey: string, assistantId?: string) => void;
367
146
  }): void {
368
- const { socketPath, handlers, signal, logger, log } = opts;
147
+ const {
148
+ socketPath,
149
+ handlers,
150
+ signal,
151
+ logger,
152
+ log,
153
+ onHandshakeComplete,
154
+ onApiKeyUpdate,
155
+ } = opts;
369
156
 
370
157
  mkdirSync(dirname(socketPath), { recursive: true });
371
158
  try {
@@ -377,10 +164,11 @@ function serveStandaloneSocket(opts: {
377
164
  const netServer = createNetServer();
378
165
 
379
166
  netServer.on("error", (err) => {
380
- log.warn({ err }, "CES standalone socket server error");
167
+ log.warn({ err }, "CES socket server error");
381
168
  });
382
169
 
383
170
  netServer.on("connection", (socket: Socket) => {
171
+ connectionCount++;
384
172
  const readable = new Readable({ read() {} });
385
173
  const writable = new Writable({
386
174
  write(chunk, _encoding, callback) {
@@ -404,19 +192,24 @@ function serveStandaloneSocket(opts: {
404
192
  handlers,
405
193
  logger,
406
194
  signal,
407
- onApiKeyUpdate: () => {},
408
- });
409
- void server.serve().catch((err) => {
410
- server.close();
411
- log.warn(
412
- { err },
413
- "CES standalone connection ended with a transport error",
414
- );
195
+ onHandshakeComplete: (sessionId, apiKey, assistantId) => {
196
+ onHandshakeComplete?.(sessionId, apiKey, assistantId);
197
+ },
198
+ onApiKeyUpdate: onApiKeyUpdate ?? (() => {}),
415
199
  });
200
+ void server
201
+ .serve()
202
+ .catch((err) => {
203
+ server.close();
204
+ log.warn({ err }, "CES connection ended with a transport error");
205
+ })
206
+ .then(() => {
207
+ connectionCount = Math.max(0, connectionCount - 1);
208
+ });
416
209
  });
417
210
 
418
211
  netServer.listen(socketPath, () => {
419
- log.info(`CES standalone socket listening at ${socketPath}`);
212
+ log.info(`CES socket listening at ${socketPath}`);
420
213
  });
421
214
 
422
215
  signal.addEventListener(
@@ -433,20 +226,89 @@ function serveStandaloneSocket(opts: {
433
226
  );
434
227
  }
435
228
 
436
- async function main(): Promise<void> {
437
- ensureDataDirs();
229
+ // ---------------------------------------------------------------------------
230
+ // Health server (managed mode only)
231
+ // ---------------------------------------------------------------------------
438
232
 
439
- initLogger({ dir: getCesLogDir(), retentionDays: 30 });
440
- const log = getLogger("main");
233
+ let connectionCount = 0;
234
+
235
+ function startHealthServer(
236
+ port: number,
237
+ signal: AbortSignal,
238
+ credentialDeps: CredentialRouteDeps | null,
239
+ ): ReturnType<typeof Bun.serve> {
240
+ const server = Bun.serve({
241
+ port,
242
+ async fetch(req) {
243
+ const url = new URL(req.url);
244
+ if (url.pathname === "/healthz") {
245
+ return new Response(JSON.stringify({ status: "ok" }), {
246
+ headers: { "Content-Type": "application/json" },
247
+ });
248
+ }
249
+ if (url.pathname === "/readyz") {
250
+ // Always return 200 — pod readiness must not depend on whether the
251
+ // assistant has connected. When the CES feature flag is off the
252
+ // assistant never connects, and a 503 here would block pod
253
+ // scheduling during dark-launch. The sidecar can't do useful work
254
+ // without a connection anyway, so readiness is purely about the
255
+ // process being up and able to accept a future connection.
256
+ return new Response(
257
+ JSON.stringify({
258
+ status: "ok",
259
+ connections: connectionCount,
260
+ rpcConnected: connectionCount > 0,
261
+ }),
262
+ {
263
+ status: 200,
264
+ headers: { "Content-Type": "application/json" },
265
+ },
266
+ );
267
+ }
268
+
269
+ // Credential CRUD routes (only if service token is configured)
270
+ if (credentialDeps) {
271
+ const credentialResponse = await handleCredentialRoute(
272
+ req,
273
+ credentialDeps,
274
+ );
275
+ if (credentialResponse) return credentialResponse;
276
+ }
277
+
278
+ // Log export route
279
+ const logExportResponse = await handleLogExportRoute(
280
+ req,
281
+ getCesLogDir("managed"),
282
+ );
283
+ if (logExportResponse) return logExportResponse;
441
284
 
442
- // `CES_STANDALONE=1` runs CES as an independent, CLI-launched sibling over a
443
- // Unix socket; otherwise CES is the assistant's stdio child, as today.
444
- const standalone = process.env["CES_STANDALONE"] === "1";
285
+ return new Response("Not Found", { status: 404 });
286
+ },
287
+ });
288
+
289
+ signal.addEventListener(
290
+ "abort",
291
+ () => {
292
+ server.stop(true);
293
+ },
294
+ { once: true },
295
+ );
296
+
297
+ return server;
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Main
302
+ // ---------------------------------------------------------------------------
303
+
304
+ async function main(): Promise<void> {
305
+ const mode = getCesMode();
306
+ ensureDataDirs(mode);
307
+
308
+ initLogger({ dir: getCesLogDir(mode), retentionDays: 30 });
445
309
 
446
310
  log.info(
447
- `Starting CES v${CES_PROTOCOL_VERSION} (local mode, ${
448
- standalone ? "standalone socket" : "stdio"
449
- } transport)`,
311
+ `Starting CES v${CES_PROTOCOL_VERSION} (${mode} mode, socket transport)`,
450
312
  );
451
313
 
452
314
  const controller = new AbortController();
@@ -459,24 +321,57 @@ async function main(): Promise<void> {
459
321
  process.on("SIGTERM", shutdown);
460
322
  process.on("SIGINT", shutdown);
461
323
 
462
- // Build the credential backend and run one-time migrations before starting
463
- // the RPC server. Migrations complete synchronously before any connection
464
- // is accepted — the backend is then passed to buildHandlers so it is not
465
- // re-instantiated.
466
- const secureKeyBackend = createLocalSecureKeyBackend(
467
- dirname(getSecurityDir()),
468
- );
324
+ // -- Secure key backend + migrations --------------------------------------
325
+ // The secure key backend path differs by mode:
326
+ // - Local: dirname(getSecurityDir()) — the parent of ~/.vellum/protected
327
+ // (or CREDENTIAL_SECURITY_DIR when set by the CLI).
328
+ // - Managed: join(assistantDataMount, ".vellum") — the assistant data mount.
329
+ const secureKeyBackend =
330
+ mode === "managed"
331
+ ? createLocalSecureKeyBackend(
332
+ join(
333
+ process.env["CES_ASSISTANT_DATA_MOUNT"] ?? "/assistant-data-ro",
334
+ ".vellum",
335
+ ),
336
+ )
337
+ : createLocalSecureKeyBackend(dirname(getSecurityDir()));
338
+
469
339
  await runCesMigrations(
470
- getCesDataRoot("local"),
340
+ getCesDataRoot(mode),
471
341
  secureKeyBackend,
472
342
  CES_MIGRATIONS,
473
343
  );
474
- log.info("CES local startup: migrations complete");
344
+ log.info(`CES ${mode} startup: migrations complete`);
345
+
346
+ // -- Build handlers --------------------------------------------------------
347
+ // The per-connection session ID lives in each CesRpcServer's SessionContext;
348
+ // handlers read it at call time. The registry is shared across connections
349
+ // and identical in both modes.
350
+ const handlers = buildCrudHandlers(secureKeyBackend);
351
+
352
+ // -- Health server (managed only) -----------------------------------------
353
+ if (mode === "managed") {
354
+ const serviceToken = process.env["CES_SERVICE_TOKEN"] ?? "";
355
+ let credentialDeps: CredentialRouteDeps | null = null;
356
+
357
+ if (serviceToken) {
358
+ credentialDeps = { backend: secureKeyBackend, serviceToken };
359
+ log.info("Credential CRUD routes enabled (CES_SERVICE_TOKEN configured)");
360
+ } else {
361
+ log.warn(
362
+ "CES_SERVICE_TOKEN not set — credential CRUD HTTP routes are disabled. " +
363
+ "Set CES_SERVICE_TOKEN to enable credential management over HTTP.",
364
+ );
365
+ }
475
366
 
476
- // Build the handler registry with all available RPC implementations.
477
- // The handshake session ID is captured per connection in the server's
478
- // SessionContext; handlers read it at call time for audit records.
479
- const handlers = buildHandlers(secureKeyBackend);
367
+ const healthPort = getHealthPort();
368
+ startHealthServer(healthPort, controller.signal, credentialDeps);
369
+ log.info(`Health server listening on port ${healthPort}`);
370
+ }
371
+
372
+ // -- Socket server ---------------------------------------------------------
373
+ const socketPath =
374
+ mode === "managed" ? getBootstrapSocketPath() : getLocalSocketPath();
480
375
 
481
376
  const rpcLog = getLogger("rpc");
482
377
  const rpcLogger = {
@@ -485,43 +380,36 @@ async function main(): Promise<void> {
485
380
  error: (msg: string, ...args: unknown[]) => rpcLog.error({ args }, msg),
486
381
  };
487
382
 
488
- if (standalone) {
489
- // Serve over a Unix socket and run until a shutdown signal — no stdio
490
- // parent to anchor the lifecycle.
491
- serveStandaloneSocket({
492
- socketPath: getLocalSocketPath(),
493
- handlers,
494
- signal: controller.signal,
495
- logger: rpcLogger,
496
- log,
497
- });
498
- await new Promise<void>((resolve) => {
499
- if (controller.signal.aborted) {
500
- resolve();
501
- return;
502
- }
503
- controller.signal.addEventListener("abort", () => resolve(), {
504
- once: true,
505
- });
506
- });
507
- log.info("Server stopped.");
508
- return;
509
- }
510
-
511
- // Default: serve the spawning assistant over stdio. stdin closing (parent
512
- // exit) or SIGTERM shuts CES down.
513
- const server = new CesRpcServer({
514
- input: process.stdin,
515
- output: process.stdout,
383
+ // Managed mode registers the `update_managed_credential` handler (via the
384
+ // `onApiKeyUpdate` hook) so the assistant can push its API key/ID after hatch.
385
+ // The push is acknowledged and logged; CES stores credentials via the CRUD
386
+ // handlers and no longer materializes platform tokens itself.
387
+ serveStandaloneSocket({
388
+ socketPath,
516
389
  handlers,
517
- logger: rpcLogger,
518
390
  signal: controller.signal,
519
- // Local mode reads API keys from env/store directly — no-op handler so
520
- // update_managed_credential is still registered and returns success.
521
- onApiKeyUpdate: () => {},
391
+ logger: rpcLogger,
392
+ log,
393
+ onApiKeyUpdate:
394
+ mode === "managed"
395
+ ? (_newKey: string, newAssistantId?: string) => {
396
+ log.info("Assistant API key updated via RPC");
397
+ if (newAssistantId) {
398
+ log.info("Assistant ID updated via RPC");
399
+ }
400
+ }
401
+ : undefined,
522
402
  });
523
403
 
524
- await server.serve();
404
+ await new Promise<void>((resolve) => {
405
+ if (controller.signal.aborted) {
406
+ resolve();
407
+ return;
408
+ }
409
+ controller.signal.addEventListener("abort", () => resolve(), {
410
+ once: true,
411
+ });
412
+ });
525
413
  log.info("Server stopped.");
526
414
  }
527
415
 
@@ -529,7 +417,7 @@ main().catch((err) => {
529
417
  try {
530
418
  getLogger("main").fatal({ err }, "Fatal error");
531
419
  } catch {
532
- process.stderr.write(`[ces-local] Fatal: ${err}\n`);
420
+ process.stderr.write(`[ces-${getCesMode()}] Fatal: ${err}\n`);
533
421
  }
534
422
  process.exit(1);
535
423
  });