@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
@@ -1,822 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Managed CES entrypoint.
4
- *
5
- * In managed (sidecar) mode the CES container:
6
- *
7
- * 1. Ensures the CES-private data directories exist.
8
- * 2. Binds a bootstrap Unix socket on the shared bootstrap volume.
9
- * 3. Accepts a single assistant runtime connection.
10
- * 4. Unlinks the socket path immediately after the connection is accepted,
11
- * preventing any second process from connecting while the session is live.
12
- * 5. Serves RPC on the accepted stream only.
13
- * 6. When that session ends (the assistant disconnects or its container is
14
- * restarted), re-binds the socket and awaits a reconnection. CES is a
15
- * long-lived sidecar — it outlives any single assistant session and only
16
- * shuts down on SIGTERM/SIGINT. At most one connection is ever active.
17
- * 7. Simultaneously serves health probes (`/healthz`, `/readyz`) on a
18
- * dedicated HTTP port for Kubernetes liveness/readiness checks.
19
- *
20
- * The managed entrypoint never opens a generic TCP or HTTP command API.
21
- * All RPC traffic flows exclusively over the accepted Unix socket stream.
22
- */
23
-
24
- import { mkdirSync, unlinkSync } from "node:fs";
25
- import { createServer as createNetServer, type Socket } from "node:net";
26
- import { dirname, join } from "node:path";
27
- import { Readable, Writable } from "node:stream";
28
-
29
- import {
30
- CES_PROTOCOL_VERSION,
31
- CesRpcMethod,
32
- } from "@vellumai/service-contracts/credential-rpc";
33
-
34
- import { AuditStore } from "./audit/store.js";
35
- import { PersistentGrantStore } from "./grants/persistent-store.js";
36
- import {
37
- createListAuditRecordsHandler,
38
- createListGrantsHandler,
39
- createRecordGrantHandler,
40
- createRevokeGrantHandler,
41
- } from "./grants/rpc-handlers.js";
42
- import { TemporaryGrantStore } from "./grants/temporary-store.js";
43
- import { initLogger, getLogger } from "./logger.js";
44
- import {
45
- getBootstrapSocketPath,
46
- getCesAuditDir,
47
- getCesDataRoot,
48
- getCesGrantsDir,
49
- getCesLogDir,
50
- getCesToolStoreDir,
51
- getHealthPort,
52
- } from "./paths.js";
53
- import {
54
- buildHandlersWithHttp,
55
- CesRpcServer,
56
- registerCommandExecutionHandler,
57
- registerManageSecureCommandToolHandler,
58
- type RpcHandlerRegistry,
59
- type ServeEndReason,
60
- } from "./server.js";
61
- import {
62
- deleteBundleFromToolstore,
63
- publishBundle,
64
- } from "./toolstore/publish.js";
65
- import { validateSourceUrl } from "./toolstore/manifest.js";
66
- import { buildCesEgressHooks } from "./commands/egress-hooks.js";
67
- import { resolveManagedSubject } from "./subjects/managed.js";
68
- import { materializeManagedToken } from "./materializers/managed-platform.js";
69
- import {
70
- HandleType,
71
- parseHandle,
72
- } from "@vellumai/service-contracts/credential-rpc";
73
- import {
74
- applyManagedCredentialRefs,
75
- buildLazyGetters,
76
- type ApiKeyRef,
77
- type AssistantIdRef,
78
- } from "./managed-lazy-getters.js";
79
- import { MANAGED_LOCAL_STATIC_REJECTION_ERROR } from "./managed-errors.js";
80
- import type { SecureKeyBackend } from "@vellumai/credential-storage";
81
- import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
82
- import type { LocalMaterialiser } from "./materializers/local.js";
83
- import type { LocalSubjectResolverDeps } from "./subjects/local.js";
84
- import {
85
- handleCredentialRoute,
86
- type CredentialRouteDeps,
87
- } from "./http/credential-routes.js";
88
- import { handleLogExportRoute } from "./http/log-export-routes.js";
89
- import { CES_MIGRATIONS } from "./migrations/registry.js";
90
- import { runCesMigrations } from "./migrations/runner.js";
91
-
92
- // ---------------------------------------------------------------------------
93
- // Logging
94
- // ---------------------------------------------------------------------------
95
-
96
- // Module-level logger used before initLogger() runs (early bootstrap) and
97
- // after it runs (structured file + stderr). Before initLogger() the fallback
98
- // inside getLogger() writes to stderr only, so early messages still appear.
99
- const log = getLogger("main");
100
-
101
- // ---------------------------------------------------------------------------
102
- // Data directory bootstrap
103
- // ---------------------------------------------------------------------------
104
-
105
- function ensureDataDirs(): void {
106
- const dirs = [
107
- getCesDataRoot("managed"),
108
- getCesGrantsDir("managed"),
109
- getCesAuditDir("managed"),
110
- getCesToolStoreDir("managed"),
111
- ];
112
- for (const dir of dirs) {
113
- mkdirSync(dir, { recursive: true });
114
- }
115
- }
116
-
117
- // ---------------------------------------------------------------------------
118
- // Build RPC handler registry (managed mode)
119
- // ---------------------------------------------------------------------------
120
-
121
- function buildHandlers(
122
- apiKeyRef: ApiKeyRef,
123
- assistantIdRef: AssistantIdRef,
124
- secureKeyBackend: SecureKeyBackend,
125
- ): RpcHandlerRegistry {
126
- // -- Grant stores ----------------------------------------------------------
127
- const persistentGrantStore = new PersistentGrantStore(
128
- getCesGrantsDir("managed"),
129
- );
130
- persistentGrantStore.init();
131
-
132
- const temporaryGrantStore = new TemporaryGrantStore();
133
-
134
- // -- Audit store -----------------------------------------------------------
135
- const auditStore = new AuditStore(getCesAuditDir("managed"));
136
- auditStore.init();
137
-
138
- // -- Managed credential options --------------------------------------------
139
- // In managed mode, credentials are obtained from the platform via its
140
- // token-materialization endpoint. The platform URL and assistant ID come
141
- // from environment variables. The API key may come from the env var OR
142
- // from the bootstrap handshake (the assistant forwards it after hatch).
143
- // We use a lazy getter so the handshake-provided key takes effect even
144
- // though handlers are built before the handshake completes.
145
- const platformBaseUrl = process.env["VELLUM_PLATFORM_URL"] ?? "";
146
-
147
- const { getManagedSubjectOptions, getManagedMaterializerOptions } =
148
- buildLazyGetters({
149
- platformBaseUrl,
150
- assistantIdRef,
151
- apiKeyRef,
152
- envApiKey: process.env["ASSISTANT_API_KEY"] || "",
153
- });
154
-
155
- if (!platformBaseUrl) {
156
- log.warn(
157
- "VELLUM_PLATFORM_URL not set. " +
158
- "Managed credential materialisation will depend on the handshake-provided values.",
159
- );
160
- }
161
-
162
- // -- Workspace root for command execution cwd ------------------------------
163
- // Use VELLUM_WORKSPACE_DIR when set, otherwise fall back to the legacy
164
- // path derived from the assistant data mount.
165
- const defaultWorkspaceDir =
166
- process.env["VELLUM_WORKSPACE_DIR"] ??
167
- (() => {
168
- const assistantDataMount =
169
- process.env["CES_ASSISTANT_DATA_MOUNT"] ?? "/assistant-data-ro";
170
- return join(join(assistantDataMount, ".vellum"), "workspace");
171
- })();
172
-
173
- // -- Build handler registry ------------------------------------------------
174
- // NOTE: local_static credential handles are NOT supported in managed mode.
175
- // v2 stores use a UID-independent `store.key` file that removes the
176
- // technical barrier (legacy v1 stores relied on PBKDF2 key derivation
177
- // from user identity, which broke across container users). The managed-
178
- // mode restriction is now a policy choice: managed deployments use
179
- // platform_oauth handles exclusively for simpler lifecycle and
180
- // centralized token management.
181
- //
182
- // We provide error-returning stubs for localMaterialiser/localSubjectDeps
183
- // so the HTTP handler compiles but any local_static request gets a clear
184
- // rejection message.
185
-
186
- const localMaterialiserStub = {
187
- materialise: async () => ({
188
- ok: false as const,
189
- error: MANAGED_LOCAL_STATIC_REJECTION_ERROR,
190
- }),
191
- };
192
-
193
- const localSubjectDepsStub: LocalSubjectResolverDeps = {
194
- metadataStore: {
195
- getById: () => undefined,
196
- list: () => [],
197
- } as unknown as LocalSubjectResolverDeps["metadataStore"],
198
- oauthConnections: { getById: () => undefined },
199
- };
200
-
201
- // Use a deps object with getters so the handshake-provided API key
202
- // is resolved lazily at RPC call time (after the handshake completes).
203
- const httpDeps = {
204
- persistentGrantStore,
205
- temporaryGrantStore,
206
- localMaterialiser: localMaterialiserStub as unknown as LocalMaterialiser,
207
- localSubjectDeps: localSubjectDepsStub,
208
- get managedSubjectOptions() {
209
- return getManagedSubjectOptions();
210
- },
211
- get managedMaterializerOptions() {
212
- return getManagedMaterializerOptions();
213
- },
214
- auditStore,
215
- };
216
-
217
- const handlers = buildHandlersWithHttp(httpDeps);
218
-
219
- // Register run_authenticated_command handler with managed platform materializer
220
- registerCommandExecutionHandler(handlers, {
221
- executorDeps: {
222
- persistentStore: persistentGrantStore,
223
- temporaryStore: temporaryGrantStore,
224
- materializeCredential: async (handle) => {
225
- // Parse handle to determine type
226
- const parseResult = parseHandle(handle);
227
- if (!parseResult.ok) {
228
- return { ok: false as const, error: parseResult.error };
229
- }
230
-
231
- switch (parseResult.handle.type) {
232
- // -- Local static: NOT supported in managed mode -------------------
233
- case HandleType.LocalStatic: {
234
- return {
235
- ok: false as const,
236
- error: MANAGED_LOCAL_STATIC_REJECTION_ERROR,
237
- };
238
- }
239
-
240
- // -- Platform OAuth: materialise via the platform endpoint ----------
241
- case HandleType.PlatformOAuth: {
242
- const matOpts = getManagedMaterializerOptions();
243
- const subOpts = getManagedSubjectOptions();
244
- if (!matOpts || !subOpts) {
245
- return {
246
- ok: false as const,
247
- error:
248
- "VELLUM_PLATFORM_URL and/or ASSISTANT_API_KEY not set. " +
249
- "Managed credential materialisation is not available.",
250
- };
251
- }
252
-
253
- const subjectResult = await resolveManagedSubject(handle, subOpts);
254
- if (!subjectResult.ok) {
255
- return { ok: false as const, error: subjectResult.error.message };
256
- }
257
-
258
- const matResult = await materializeManagedToken(
259
- subjectResult.subject,
260
- matOpts,
261
- );
262
- if (!matResult.ok) {
263
- return { ok: false as const, error: matResult.error.message };
264
- }
265
-
266
- return {
267
- ok: true as const,
268
- value: matResult.token.accessToken,
269
- handleType: HandleType.PlatformOAuth,
270
- };
271
- }
272
-
273
- default:
274
- return {
275
- ok: false as const,
276
- error:
277
- `Handle type "${parseResult.handle.type}" is not supported in managed mode. ` +
278
- `Supported types: platform_oauth.`,
279
- };
280
- }
281
- },
282
- auditStore,
283
- cesMode: "managed",
284
- egressHooks: buildCesEgressHooks(),
285
- },
286
- defaultWorkspaceDir,
287
- });
288
-
289
- // Register manage_secure_command_tool handler
290
- const toolRegistry = new Map<
291
- string,
292
- {
293
- toolName: string;
294
- credentialHandle: string;
295
- description: string;
296
- bundleDigest: string;
297
- }
298
- >();
299
-
300
- registerManageSecureCommandToolHandler(handlers, {
301
- downloadBundle: async (sourceUrl: string) => {
302
- const urlError = validateSourceUrl(sourceUrl);
303
- if (urlError) {
304
- throw new Error(urlError);
305
- }
306
- const MAX_BUNDLE_SIZE = 100 * 1024 * 1024; // 100 MB
307
- const resp = await fetch(sourceUrl, {
308
- signal: AbortSignal.timeout(60_000),
309
- });
310
- if (!resp.ok) {
311
- throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
312
- }
313
- const contentLength = resp.headers.get("content-length");
314
- if (contentLength && parseInt(contentLength, 10) > MAX_BUNDLE_SIZE) {
315
- throw new Error(
316
- `Bundle too large: ${contentLength} bytes (max ${MAX_BUNDLE_SIZE})`,
317
- );
318
- }
319
- // Stream the body and enforce the size limit on actual bytes received,
320
- // since Content-Length can be absent (chunked encoding) or lie.
321
- const body = resp.body;
322
- if (!body) {
323
- throw new Error("Response body is null");
324
- }
325
- const chunks: Uint8Array[] = [];
326
- let totalBytes = 0;
327
- for await (const chunk of body) {
328
- totalBytes += chunk.byteLength;
329
- if (totalBytes > MAX_BUNDLE_SIZE) {
330
- throw new Error(
331
- `Bundle too large: received >${MAX_BUNDLE_SIZE} bytes (max ${MAX_BUNDLE_SIZE})`,
332
- );
333
- }
334
- chunks.push(chunk);
335
- }
336
- return Buffer.concat(chunks);
337
- },
338
- publishBundle: (request) =>
339
- publishBundle({ ...request, cesMode: "managed" }),
340
- unregisterTool: (toolName: string) => {
341
- const entry = toolRegistry.get(toolName);
342
- const removed = toolRegistry.delete(toolName);
343
- if (removed && entry?.bundleDigest) {
344
- const stillInUse = Array.from(toolRegistry.values()).some(
345
- (t) => t.bundleDigest === entry.bundleDigest,
346
- );
347
- if (!stillInUse) {
348
- deleteBundleFromToolstore(entry.bundleDigest, "managed");
349
- }
350
- }
351
- return removed;
352
- },
353
- registerTool: (entry) => {
354
- toolRegistry.set(entry.toolName, entry);
355
- },
356
- });
357
-
358
- // Register grant management handlers
359
- handlers[CesRpcMethod.RecordGrant] = createRecordGrantHandler({
360
- persistentGrantStore,
361
- temporaryGrantStore,
362
- }) as (typeof handlers)[string];
363
-
364
- handlers[CesRpcMethod.ListGrants] = createListGrantsHandler({
365
- persistentGrantStore,
366
- }) as (typeof handlers)[string];
367
-
368
- handlers[CesRpcMethod.RevokeGrant] = createRevokeGrantHandler({
369
- persistentGrantStore,
370
- }) as (typeof handlers)[string];
371
-
372
- // Register audit record handler
373
- handlers[CesRpcMethod.ListAuditRecords] = createListAuditRecordsHandler({
374
- auditStore,
375
- }) as (typeof handlers)[string];
376
-
377
- // Register credential CRUD handlers
378
- handlers[CesRpcMethod.GetCredential] = (async (req: { account: string }) => {
379
- const value = await secureKeyBackend.get(req.account);
380
- return { found: value !== undefined, value };
381
- }) as (typeof handlers)[string];
382
-
383
- handlers[CesRpcMethod.SetCredential] = (async (req: {
384
- account: string;
385
- value: string;
386
- }) => {
387
- const ok = await secureKeyBackend.set(req.account, req.value);
388
- return { ok };
389
- }) as (typeof handlers)[string];
390
-
391
- handlers[CesRpcMethod.DeleteCredential] = (async (req: {
392
- account: string;
393
- }) => {
394
- const result = await secureKeyBackend.delete(req.account);
395
- return { result };
396
- }) as (typeof handlers)[string];
397
-
398
- handlers[CesRpcMethod.ListCredentials] = (async () => {
399
- const accounts = await secureKeyBackend.list();
400
- return { accounts };
401
- }) as (typeof handlers)[string];
402
-
403
- handlers[CesRpcMethod.BulkSetCredentials] = (async (req: {
404
- credentials: Array<{ account: string; value: string }>;
405
- }) => {
406
- const results = [];
407
- for (const { account, value } of req.credentials) {
408
- const ok = await secureKeyBackend.set(account, value);
409
- results.push({ account, ok });
410
- }
411
- return { results };
412
- }) as (typeof handlers)[string];
413
-
414
- return handlers;
415
- }
416
-
417
- // ---------------------------------------------------------------------------
418
- // Health server
419
- // ---------------------------------------------------------------------------
420
-
421
- let rpcConnected = false;
422
-
423
- function startHealthServer(
424
- port: number,
425
- signal: AbortSignal,
426
- credentialDeps: CredentialRouteDeps | null,
427
- ): ReturnType<typeof Bun.serve> {
428
- const server = Bun.serve({
429
- port,
430
- async fetch(req) {
431
- const url = new URL(req.url);
432
- if (url.pathname === "/healthz") {
433
- return new Response(JSON.stringify({ status: "ok" }), {
434
- headers: { "Content-Type": "application/json" },
435
- });
436
- }
437
- if (url.pathname === "/readyz") {
438
- // Always return 200 — pod readiness must not depend on whether the
439
- // assistant has connected. When the CES feature flag is off the
440
- // assistant never connects, and a 503 here would block pod
441
- // scheduling during dark-launch. The sidecar can't do useful work
442
- // without a connection anyway, so readiness is purely about the
443
- // process being up and able to accept a future connection.
444
- return new Response(JSON.stringify({ status: "ok", rpcConnected }), {
445
- status: 200,
446
- headers: { "Content-Type": "application/json" },
447
- });
448
- }
449
-
450
- // Credential CRUD routes (only if service token is configured)
451
- if (credentialDeps) {
452
- const credentialResponse = await handleCredentialRoute(
453
- req,
454
- credentialDeps,
455
- );
456
- if (credentialResponse) return credentialResponse;
457
- }
458
-
459
- // Log export route
460
- const logExportResponse = await handleLogExportRoute(
461
- req,
462
- getCesLogDir("managed"),
463
- );
464
- if (logExportResponse) return logExportResponse;
465
-
466
- return new Response("Not Found", { status: 404 });
467
- },
468
- });
469
-
470
- signal.addEventListener(
471
- "abort",
472
- () => {
473
- server.stop(true);
474
- },
475
- { once: true },
476
- );
477
-
478
- return server;
479
- }
480
-
481
- // ---------------------------------------------------------------------------
482
- // Bootstrap socket server (accepts one connection at a time)
483
- // ---------------------------------------------------------------------------
484
-
485
- /**
486
- * Listen on a Unix socket, accept one connection, unlink the socket path,
487
- * and return readable/writable streams for the accepted connection.
488
- *
489
- * The socket is unlinked while a connection is active so no second process
490
- * can connect concurrently (only one assistant ever talks to CES at a time).
491
- * When that session ends, the caller re-invokes this function to re-bind the
492
- * socket and accept the assistant's reconnection — CES outlives any single
493
- * assistant session (see `main()`).
494
- */
495
- function acceptOneConnection(
496
- socketPath: string,
497
- signal: AbortSignal,
498
- ): Promise<{ readable: Readable; writable: Writable; socket: Socket }> {
499
- return new Promise((resolve, reject) => {
500
- // Ensure the socket directory exists
501
- mkdirSync(dirname(socketPath), { recursive: true });
502
-
503
- // Clean up any stale socket file
504
- try {
505
- unlinkSync(socketPath);
506
- } catch {
507
- // Ignore — file may not exist
508
- }
509
-
510
- const netServer = createNetServer();
511
-
512
- const cleanup = () => {
513
- netServer.close();
514
- try {
515
- unlinkSync(socketPath);
516
- } catch {
517
- // Already unlinked or never created
518
- }
519
- };
520
-
521
- if (signal.aborted) {
522
- reject(new Error("Aborted before listening"));
523
- return;
524
- }
525
-
526
- // Remove this listener once the promise settles. Because CES re-binds
527
- // the socket after each session ends, a long-lived AbortSignal would
528
- // otherwise accumulate one dangling listener per reconnection.
529
- const onAbort = () => {
530
- cleanup();
531
- reject(new Error("Aborted while waiting for connection"));
532
- };
533
- signal.addEventListener("abort", onAbort, { once: true });
534
-
535
- netServer.on("error", (err) => {
536
- signal.removeEventListener("abort", onAbort);
537
- cleanup();
538
- reject(err);
539
- });
540
-
541
- netServer.listen(socketPath, () => {
542
- log.info(`Bootstrap socket listening at ${socketPath}`);
543
- });
544
-
545
- netServer.on("connection", (socket: Socket) => {
546
- // Accept the connection, then close the listener and unlink the
547
- // socket path so no other process can connect while this session
548
- // is active.
549
- signal.removeEventListener("abort", onAbort);
550
- log.info("Assistant connected via bootstrap socket");
551
- netServer.close();
552
- try {
553
- unlinkSync(socketPath);
554
- } catch {
555
- // Already unlinked
556
- }
557
- log.info("Bootstrap socket unlinked (single active connection enforced)");
558
-
559
- const readable = new Readable({
560
- read() {
561
- // Data is pushed externally
562
- },
563
- });
564
-
565
- const writable = new Writable({
566
- write(chunk, _encoding, callback) {
567
- if (socket.writable) {
568
- socket.write(chunk, callback);
569
- } else {
570
- callback(new Error("Socket no longer writable"));
571
- }
572
- },
573
- });
574
-
575
- socket.on("data", (chunk) => {
576
- readable.push(chunk);
577
- });
578
-
579
- socket.on("end", () => {
580
- readable.push(null);
581
- });
582
-
583
- socket.on("error", (err) => {
584
- readable.destroy(err);
585
- writable.destroy(err);
586
- });
587
-
588
- resolve({ readable, writable, socket });
589
- });
590
- });
591
- }
592
-
593
- // ---------------------------------------------------------------------------
594
- // Main
595
- // ---------------------------------------------------------------------------
596
-
597
- async function main(): Promise<void> {
598
- ensureDataDirs();
599
-
600
- initLogger({ dir: getCesLogDir("managed"), retentionDays: 30 });
601
-
602
- log.info(`Starting CES v${CES_PROTOCOL_VERSION} (managed mode)`);
603
-
604
- const controller = new AbortController();
605
-
606
- // Graceful shutdown — pass the signal as the abort reason so consumers
607
- // of controller.signal can inspect signal.reason for triage.
608
- process.on("SIGTERM", () => {
609
- log.warn(
610
- { signal: "SIGTERM", pid: process.pid, uptime: process.uptime() },
611
- "Received SIGTERM — shutting down",
612
- );
613
- controller.abort("SIGTERM");
614
- });
615
- process.on("SIGINT", () => {
616
- log.warn(
617
- { signal: "SIGINT", pid: process.pid, uptime: process.uptime() },
618
- "Received SIGINT — shutting down",
619
- );
620
- controller.abort("SIGINT");
621
- });
622
-
623
- // Create the secure key backend unconditionally — it's needed by both
624
- // HTTP credential routes (when CES_SERVICE_TOKEN is set) and RPC
625
- // credential CRUD handlers (always available).
626
- const assistantDataMount =
627
- process.env["CES_ASSISTANT_DATA_MOUNT"] ?? "/assistant-data-ro";
628
- const vellumRoot = join(assistantDataMount, ".vellum");
629
- const secureKeyBackend = createLocalSecureKeyBackend(vellumRoot);
630
-
631
- // Run one-time credential store migrations before accepting connections.
632
- await runCesMigrations(
633
- getCesDataRoot("managed"),
634
- secureKeyBackend,
635
- CES_MIGRATIONS,
636
- );
637
- log.info("CES managed startup: migrations complete");
638
-
639
- // Set up credential CRUD routes if a service token is configured.
640
- // The assistant and gateway use CES_SERVICE_TOKEN to authenticate
641
- // credential management requests over HTTP.
642
- const serviceToken = process.env["CES_SERVICE_TOKEN"] ?? "";
643
- let credentialDeps: CredentialRouteDeps | null = null;
644
-
645
- if (serviceToken) {
646
- credentialDeps = { backend: secureKeyBackend, serviceToken };
647
- log.info("Credential CRUD routes enabled (CES_SERVICE_TOKEN configured)");
648
- } else {
649
- log.warn(
650
- "CES_SERVICE_TOKEN not set — credential CRUD HTTP routes are disabled. " +
651
- "Set CES_SERVICE_TOKEN to enable credential management over HTTP.",
652
- );
653
- }
654
-
655
- // Start health server on dedicated port. The returned handle isn't
656
- // needed because the server lifetime is bound to controller.signal,
657
- // which fires on shutdown and triggers Bun.serve's stop().
658
- const healthPort = getHealthPort();
659
- startHealthServer(healthPort, controller.signal, credentialDeps);
660
- log.info(`Health server listening on port ${healthPort}`);
661
-
662
- // Build the handler registry once, up front, and reuse it across every
663
- // assistant session. All CES state lives behind these handlers — file-backed
664
- // grant/audit stores plus the in-memory temporary-grant store and the
665
- // secure-command tool registry — and must be process-scoped so it survives
666
- // an assistant reconnection. In particular, the tool registry mirrors the
667
- // persistent toolstore on disk; rebuilding it per session would let a later
668
- // `unregister` miss a tool registered in an earlier session and orphan its
669
- // bundle.
670
- //
671
- // The in-memory temporary-grant store instance is also process-scoped and is
672
- // deliberately reused — contents included — across reconnects. Ephemeral
673
- // approvals (`allow_once` / `allow_10m` / `allow_conversation`) are keyed by
674
- // proposal hash (plus a caller-supplied conversation ID), not by the
675
- // connection that produced them, precisely so a single guardian approval can
676
- // be shared by any connection entitled to use it. That sharing is what the
677
- // multi-process daemon model needs: several assistant processes will each
678
- // talk to CES, and an approval granted while one is connected must remain
679
- // usable by the others. Grant lifetime is therefore bounded by per-grant TTLs
680
- // (every kind now carries an expiry), not by tearing the store down on
681
- // disconnect — so an approval that is never consumed expires on its own
682
- // instead of surviving indefinitely and being replayed by a much later
683
- // connection without a fresh guardian prompt (ATL-935). A future
684
- // multi-connection daemon may additionally evict on quiescence (when the
685
- // count of live CES connections reaches zero) to scope grants to assistant
686
- // presence; that is connection-lifecycle machinery the multi-connection work
687
- // should own, and is intentionally not added here.
688
- //
689
- // The mutable refs carry the handshake-provided API key and assistant ID;
690
- // handlers read them at call time. These don't vary across a daemon's
691
- // connections, so they stay process-global. The per-connection session ID,
692
- // by contrast, lives in each CesRpcServer's SessionContext (handlers read it
693
- // at call time for audit attribution).
694
- const apiKeyRef: ApiKeyRef = { current: "" };
695
- const assistantIdRef: AssistantIdRef = { current: "" };
696
- const handlers = buildHandlers(apiKeyRef, assistantIdRef, secureKeyBackend);
697
-
698
- // Serve loop. CES is a long-lived sidecar that must outlive any single
699
- // assistant session: the assistant container can crash and be restarted
700
- // independently of the CES container (Kubernetes restarts containers, not
701
- // the whole pod), so when the RPC stream ends we re-bind the bootstrap
702
- // socket and wait for the assistant to reconnect rather than tearing the
703
- // sidecar down. The loop only exits on a shutdown signal (SIGTERM/SIGINT),
704
- // which aborts the controller.
705
- const rpcLog = getLogger("rpc");
706
- const socketPath = getBootstrapSocketPath();
707
-
708
- while (!controller.signal.aborted) {
709
- log.info(`Waiting for assistant connection on ${socketPath}...`);
710
-
711
- let connection: Awaited<ReturnType<typeof acceptOneConnection>>;
712
- try {
713
- connection = await acceptOneConnection(socketPath, controller.signal);
714
- } catch (err) {
715
- if (controller.signal.aborted) {
716
- log.info("Shutdown before assistant connected.");
717
- return;
718
- }
719
- throw err;
720
- }
721
-
722
- rpcConnected = true;
723
-
724
- const server = new CesRpcServer({
725
- input: connection.readable,
726
- output: connection.writable,
727
- handlers,
728
- logger: {
729
- log: (msg: string, ...args: unknown[]) => rpcLog.info({ args }, msg),
730
- warn: (msg: string, ...args: unknown[]) => rpcLog.warn({ args }, msg),
731
- error: (msg: string, ...args: unknown[]) => rpcLog.error({ args }, msg),
732
- },
733
- signal: controller.signal,
734
- onHandshakeComplete: (_hsSessionId, hsApiKey, hsAssistantId) => {
735
- // Overwrite the credential refs on every handshake. The handler
736
- // registry persists across reconnects, so a new session that omits
737
- // the API key / assistant ID must fail closed (falling back to the
738
- // env key, or no key) rather than reusing the previous session's
739
- // credentials.
740
- applyManagedCredentialRefs(
741
- apiKeyRef,
742
- assistantIdRef,
743
- hsApiKey,
744
- hsAssistantId,
745
- );
746
- if (hsApiKey) {
747
- log.info("Received assistant API key via handshake");
748
- }
749
- if (hsAssistantId) {
750
- log.info("Received assistant ID via handshake");
751
- }
752
- },
753
- onApiKeyUpdate: (newKey, newAssistantId) => {
754
- // Overwrite both refs on every credential update, for the same
755
- // fail-closed reason as the handshake: the assistant sources the
756
- // assistant ID from the same place it sources the key, so an update
757
- // that omits the ID means it has none — CES must clear the stale ID
758
- // rather than keep materializing for the previous session's assistant.
759
- applyManagedCredentialRefs(
760
- apiKeyRef,
761
- assistantIdRef,
762
- newKey,
763
- newAssistantId,
764
- );
765
- log.info("Assistant API key updated via RPC");
766
- if (newAssistantId) {
767
- log.info("Assistant ID updated via RPC");
768
- }
769
- },
770
- });
771
-
772
- // `serve()` resolves on a clean stream end or signal abort, and rejects
773
- // when the transport stream errors — which is precisely what a hard
774
- // disconnect (connection reset when the assistant container crashes)
775
- // looks like. Both cases must keep the sidecar up; only a shutdown
776
- // signal should tear it down. So treat a serve() rejection the same as
777
- // a session end and fall through to await reconnection.
778
- let endReason: ServeEndReason | "transport_error";
779
- try {
780
- endReason = await server.serve();
781
- } catch (err) {
782
- server.close();
783
- endReason = "transport_error";
784
- log.warn(
785
- { err, uptime: process.uptime(), pid: process.pid },
786
- "RPC transport errored — treating as session end",
787
- );
788
- }
789
-
790
- rpcConnected = false;
791
-
792
- // A signal-driven end means the process is shutting down; exit the loop.
793
- // Any other end reason (the assistant disconnected, its stream closed,
794
- // or the transport errored) means we keep the sidecar up and await a
795
- // reconnection.
796
- if (
797
- controller.signal.aborted ||
798
- endReason === "signal_aborted" ||
799
- endReason === "signal_aborted_before_start"
800
- ) {
801
- log.info(
802
- { reason: endReason, uptime: process.uptime(), pid: process.pid },
803
- "RPC session ended due to shutdown — exiting serve loop",
804
- );
805
- break;
806
- }
807
-
808
- log.warn(
809
- { reason: endReason, uptime: process.uptime(), pid: process.pid },
810
- "RPC session ended (assistant disconnected) — awaiting reconnection",
811
- );
812
- }
813
- }
814
-
815
- main().catch((err) => {
816
- try {
817
- getLogger("main").fatal({ err }, "Fatal error");
818
- } catch {
819
- process.stderr.write(`[ces-managed] Fatal: ${err}\n`);
820
- }
821
- process.exit(1);
822
- });