@vellumai/credential-executor 0.10.7-dev.202607102035.64f07ea → 0.10.7-staging.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 (65) hide show
  1. package/Dockerfile +1 -1
  2. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  3. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +2 -0
  4. package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +686 -0
  5. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +5 -3
  6. package/node_modules/@vellumai/service-contracts/src/grants.ts +184 -0
  7. package/node_modules/@vellumai/service-contracts/src/index.ts +4 -2
  8. package/node_modules/@vellumai/service-contracts/src/rendering.ts +135 -0
  9. package/node_modules/@vellumai/service-contracts/src/rpc.ts +447 -4
  10. package/package.json +3 -2
  11. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  12. package/src/__tests__/command-executor.test.ts +1879 -0
  13. package/src/__tests__/command-validator.test.ts +1405 -0
  14. package/src/__tests__/command-workspace.test.ts +1050 -0
  15. package/src/__tests__/grant-store.test.ts +689 -0
  16. package/src/__tests__/http-executor.test.ts +1336 -0
  17. package/src/__tests__/http-policy.test.ts +1069 -0
  18. package/src/__tests__/local-materializers.test.ts +860 -0
  19. package/src/__tests__/local-standalone.test.ts +36 -5
  20. package/src/__tests__/local-token-refresh.test.ts +361 -0
  21. package/src/__tests__/manage-secure-command-tool.test.ts +134 -0
  22. package/src/__tests__/managed-integration.test.ts +91 -112
  23. package/src/__tests__/managed-lazy-getters.test.ts +359 -0
  24. package/src/__tests__/managed-materializers.test.ts +1028 -0
  25. package/src/__tests__/managed-reconnect.test.ts +2 -2
  26. package/src/__tests__/managed-rejection.test.ts +43 -0
  27. package/src/__tests__/toolstore.test.ts +773 -0
  28. package/src/__tests__/transport.test.ts +27 -23
  29. package/src/audit/store.ts +188 -0
  30. package/src/cli.ts +1 -1
  31. package/src/commands/auth-adapters.ts +169 -0
  32. package/src/commands/egress-hooks.ts +203 -0
  33. package/src/commands/executor.ts +1155 -0
  34. package/src/commands/output-scan.ts +157 -0
  35. package/src/commands/profiles.ts +286 -0
  36. package/src/commands/validator.ts +702 -0
  37. package/src/commands/workspace.ts +550 -0
  38. package/src/grants/index.ts +17 -0
  39. package/src/grants/persistent-store.ts +309 -0
  40. package/src/grants/rpc-handlers.ts +293 -0
  41. package/src/grants/temporary-store.ts +289 -0
  42. package/src/http/audit.ts +84 -0
  43. package/src/http/executor.ts +684 -0
  44. package/src/http/path-template.ts +245 -0
  45. package/src/http/policy.ts +238 -0
  46. package/src/http/response-filter.ts +233 -0
  47. package/src/index.ts +88 -8
  48. package/src/main.ts +340 -228
  49. package/src/managed-errors.ts +9 -0
  50. package/src/managed-lazy-getters.ts +106 -0
  51. package/src/managed-main.ts +822 -0
  52. package/src/materializers/local-oauth-lookup.ts +98 -0
  53. package/src/materializers/local-token-refresh.ts +287 -0
  54. package/src/materializers/local.ts +316 -0
  55. package/src/materializers/managed-platform.ts +295 -0
  56. package/src/paths.ts +20 -4
  57. package/src/server.ts +469 -52
  58. package/src/subjects/local.ts +177 -0
  59. package/src/subjects/managed.ts +311 -0
  60. package/src/subjects/policy.ts +79 -0
  61. package/src/toolstore/integrity.ts +94 -0
  62. package/src/toolstore/manifest.ts +154 -0
  63. package/src/toolstore/publish.ts +571 -0
  64. package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +0 -104
  65. package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +0 -118
package/src/main.ts CHANGED
@@ -1,24 +1,26 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * CES (Credential Execution Service) entrypoint — unified for both
4
- * local (bare-metal sibling) and managed (Kubernetes sidecar) modes.
3
+ * Local CES entrypoint. Two run modes:
5
4
  *
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)
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.
9
8
  *
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.
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.
15
14
  *
16
- * Managed mode additionally starts a health HTTP server (`/healthz`, `/readyz`,
17
- * optional credential CRUD routes) for Kubernetes liveness/readiness probes.
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.
18
19
  */
19
20
 
20
21
  import { mkdirSync, unlinkSync } from "node:fs";
21
22
  import { createServer as createNetServer, type Socket } from "node:net";
23
+ import { homedir } from "node:os";
22
24
  import { dirname, join } from "node:path";
23
25
  import { Readable, Writable } from "node:stream";
24
26
 
@@ -26,60 +28,285 @@ import {
26
28
  CES_PROTOCOL_VERSION,
27
29
  CesRpcMethod,
28
30
  } from "@vellumai/service-contracts/credential-rpc";
29
- import type { SecureKeyBackend } from "@vellumai/credential-storage";
31
+ import { StaticCredentialMetadataStore } from "@vellumai/credential-storage";
30
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
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
31
44
  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";
32
49
  import { initLogger, getLogger } from "./logger.js";
33
50
  import {
34
- getBootstrapSocketPath,
51
+ getCesAuditDir,
35
52
  getCesDataRoot,
53
+ getCesGrantsDir,
36
54
  getCesLogDir,
37
- getCesMode,
38
- getHealthPort,
55
+ getCesToolStoreDir,
39
56
  getLocalSocketPath,
40
- getSecurityDir,
41
- type CesMode,
42
57
  } from "./paths.js";
43
- import { CesRpcServer, type RpcHandlerRegistry } from "./server.js";
44
58
  import {
45
- handleCredentialRoute,
46
- type CredentialRouteDeps,
47
- } from "./http/credential-routes.js";
48
- import { handleLogExportRoute } from "./http/log-export-routes.js";
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";
49
71
  import { CES_MIGRATIONS } from "./migrations/registry.js";
50
72
  import { runCesMigrations } from "./migrations/runner.js";
51
73
 
52
74
  // ---------------------------------------------------------------------------
53
- // Logging (module-level for early bootstrap + structured logging post-init)
75
+ // Data directory bootstrap
54
76
  // ---------------------------------------------------------------------------
55
77
 
56
- const log = getLogger("main");
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
+ }
57
89
 
58
90
  // ---------------------------------------------------------------------------
59
- // Data directory bootstrap
91
+ // Path resolution
60
92
  // ---------------------------------------------------------------------------
61
93
 
62
- function ensureDataDirs(mode: CesMode): void {
63
- mkdirSync(getCesDataRoot(mode), { recursive: true });
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
+ );
64
121
  }
65
122
 
66
123
  // ---------------------------------------------------------------------------
67
- // Credential CRUD handlers
124
+ // Build RPC handler registry
68
125
  // ---------------------------------------------------------------------------
69
126
 
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 = {};
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];
82
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];
308
+
309
+ // Register credential CRUD handlers
83
310
  handlers[CesRpcMethod.GetCredential] = (async (req: { account: string }) => {
84
311
  const value = await secureKeyBackend.get(req.account);
85
312
  return { found: value !== undefined, value };
@@ -120,16 +347,16 @@ function buildCrudHandlers(
120
347
  }
121
348
 
122
349
  // ---------------------------------------------------------------------------
123
- // Socket server (unified multi-connection model)
350
+ // Main
124
351
  // ---------------------------------------------------------------------------
125
352
 
126
353
  /**
127
- * Serve RPC over a Unix socket using a concurrent multi-connection model.
354
+ * Serve RPC over a Unix socket for standalone-sibling mode.
128
355
  *
129
356
  * Binds the socket, accepts connections concurrently (each served by its own
130
357
  * CesRpcServer over the shared handler registry), and unlinks the socket when
131
- * the signal aborts. The listener stays open across connections so a client
132
- * that disconnects can reconnect without CES re-binding.
358
+ * the signal aborts. Temporary scaffolding — this serving path will be folded
359
+ * into a single unified CES entrypoint shared with the managed sidecar.
133
360
  */
134
361
  function serveStandaloneSocket(opts: {
135
362
  socketPath: string;
@@ -137,22 +364,8 @@ function serveStandaloneSocket(opts: {
137
364
  signal: AbortSignal;
138
365
  logger: Pick<Console, "log" | "warn" | "error">;
139
366
  log: ReturnType<typeof getLogger>;
140
- onHandshakeComplete?: (
141
- sessionId: string,
142
- assistantApiKey?: string,
143
- assistantId?: string,
144
- ) => void;
145
- onApiKeyUpdate?: (assistantApiKey: string, assistantId?: string) => void;
146
367
  }): void {
147
- const {
148
- socketPath,
149
- handlers,
150
- signal,
151
- logger,
152
- log,
153
- onHandshakeComplete,
154
- onApiKeyUpdate,
155
- } = opts;
368
+ const { socketPath, handlers, signal, logger, log } = opts;
156
369
 
157
370
  mkdirSync(dirname(socketPath), { recursive: true });
158
371
  try {
@@ -164,11 +377,10 @@ function serveStandaloneSocket(opts: {
164
377
  const netServer = createNetServer();
165
378
 
166
379
  netServer.on("error", (err) => {
167
- log.warn({ err }, "CES socket server error");
380
+ log.warn({ err }, "CES standalone socket server error");
168
381
  });
169
382
 
170
383
  netServer.on("connection", (socket: Socket) => {
171
- connectionCount++;
172
384
  const readable = new Readable({ read() {} });
173
385
  const writable = new Writable({
174
386
  write(chunk, _encoding, callback) {
@@ -192,24 +404,19 @@ function serveStandaloneSocket(opts: {
192
404
  handlers,
193
405
  logger,
194
406
  signal,
195
- onHandshakeComplete: (sessionId, apiKey, assistantId) => {
196
- onHandshakeComplete?.(sessionId, apiKey, assistantId);
197
- },
198
- onApiKeyUpdate: onApiKeyUpdate ?? (() => {}),
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
+ );
199
415
  });
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
- });
209
416
  });
210
417
 
211
418
  netServer.listen(socketPath, () => {
212
- log.info(`CES socket listening at ${socketPath}`);
419
+ log.info(`CES standalone socket listening at ${socketPath}`);
213
420
  });
214
421
 
215
422
  signal.addEventListener(
@@ -226,89 +433,20 @@ function serveStandaloneSocket(opts: {
226
433
  );
227
434
  }
228
435
 
229
- // ---------------------------------------------------------------------------
230
- // Health server (managed mode only)
231
- // ---------------------------------------------------------------------------
232
-
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;
284
-
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
436
  async function main(): Promise<void> {
305
- const mode = getCesMode();
306
- ensureDataDirs(mode);
437
+ ensureDataDirs();
307
438
 
308
- initLogger({ dir: getCesLogDir(mode), retentionDays: 30 });
439
+ initLogger({ dir: getCesLogDir(), retentionDays: 30 });
440
+ const log = getLogger("main");
441
+
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";
309
445
 
310
446
  log.info(
311
- `Starting CES v${CES_PROTOCOL_VERSION} (${mode} mode, socket transport)`,
447
+ `Starting CES v${CES_PROTOCOL_VERSION} (local mode, ${
448
+ standalone ? "standalone socket" : "stdio"
449
+ } transport)`,
312
450
  );
313
451
 
314
452
  const controller = new AbortController();
@@ -321,57 +459,24 @@ async function main(): Promise<void> {
321
459
  process.on("SIGTERM", shutdown);
322
460
  process.on("SIGINT", shutdown);
323
461
 
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
-
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
+ );
339
469
  await runCesMigrations(
340
- getCesDataRoot(mode),
470
+ getCesDataRoot("local"),
341
471
  secureKeyBackend,
342
472
  CES_MIGRATIONS,
343
473
  );
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
- }
474
+ log.info("CES local startup: migrations complete");
366
475
 
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();
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);
375
480
 
376
481
  const rpcLog = getLogger("rpc");
377
482
  const rpcLogger = {
@@ -380,36 +485,43 @@ async function main(): Promise<void> {
380
485
  error: (msg: string, ...args: unknown[]) => rpcLog.error({ args }, msg),
381
486
  };
382
487
 
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,
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,
389
516
  handlers,
390
- signal: controller.signal,
391
517
  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,
518
+ 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: () => {},
402
522
  });
403
523
 
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
- });
524
+ await server.serve();
413
525
  log.info("Server stopped.");
414
526
  }
415
527
 
@@ -417,7 +529,7 @@ main().catch((err) => {
417
529
  try {
418
530
  getLogger("main").fatal({ err }, "Fatal error");
419
531
  } catch {
420
- process.stderr.write(`[ces-${getCesMode()}] Fatal: ${err}\n`);
532
+ process.stderr.write(`[ces-local] Fatal: ${err}\n`);
421
533
  }
422
534
  process.exit(1);
423
535
  });