@vellumai/credential-executor 0.5.12 → 0.5.13

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.
@@ -33,6 +33,13 @@ export const HandshakeRequestSchema = z.object({
33
33
  * can use it for platform credential materialisation.
34
34
  */
35
35
  assistantApiKey: z.string().optional(),
36
+ /**
37
+ * Optional platform assistant ID passed from the assistant runtime.
38
+ * In managed (sidecar) mode with warm pools, the PLATFORM_ASSISTANT_ID
39
+ * env var may not be set at CES startup. The assistant forwards it here
40
+ * so CES can use it for platform credential materialisation.
41
+ */
42
+ assistantId: z.string().optional(),
36
43
  });
37
44
  export type HandshakeRequest = z.infer<typeof HandshakeRequestSchema>;
38
45
 
@@ -422,6 +422,11 @@ export type ListAuditRecordsResponse = z.infer<
422
422
  export const UpdateManagedCredentialSchema = z.object({
423
423
  /** The assistant API key to push to CES for platform credential materialization. */
424
424
  assistantApiKey: z.string(), // nosemgrep: not-a-secret
425
+ /**
426
+ * Optional platform assistant ID. In warm-pool mode the ID may not be
427
+ * available at CES startup; the assistant pushes it here once provisioned.
428
+ */
429
+ assistantId: z.string().optional(),
425
430
  });
426
431
  export type UpdateManagedCredential = z.infer<
427
432
  typeof UpdateManagedCredentialSchema
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.5.12",
3
+ "version": "0.5.13",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,6 +11,7 @@ import { describe, expect, test } from "bun:test";
11
11
  import {
12
12
  buildLazyGetters,
13
13
  type ApiKeyRef,
14
+ type AssistantIdRef,
14
15
  } from "../managed-lazy-getters.js";
15
16
 
16
17
  // ---------------------------------------------------------------------------
@@ -20,9 +21,10 @@ import {
20
21
  describe("managed lazy getters — before API key arrives", () => {
21
22
  test("apiKeyRef starts empty and managed subject options are undefined", () => {
22
23
  const apiKeyRef: ApiKeyRef = { current: "" };
24
+ const assistantIdRef: AssistantIdRef = { current: "ast_abc123" };
23
25
  const { getManagedSubjectOptions } = buildLazyGetters({
24
26
  platformBaseUrl: "https://api.vellum.ai",
25
- assistantId: "ast_abc123",
27
+ assistantIdRef,
26
28
  apiKeyRef,
27
29
  });
28
30
 
@@ -32,9 +34,10 @@ describe("managed lazy getters — before API key arrives", () => {
32
34
 
33
35
  test("apiKeyRef starts empty and managed materializer options are undefined", () => {
34
36
  const apiKeyRef: ApiKeyRef = { current: "" };
37
+ const assistantIdRef: AssistantIdRef = { current: "ast_abc123" };
35
38
  const { getManagedMaterializerOptions } = buildLazyGetters({
36
39
  platformBaseUrl: "https://api.vellum.ai",
37
- assistantId: "ast_abc123",
40
+ assistantIdRef,
38
41
  apiKeyRef,
39
42
  });
40
43
 
@@ -43,9 +46,10 @@ describe("managed lazy getters — before API key arrives", () => {
43
46
 
44
47
  test("getAssistantApiKey returns empty string when ref is empty and no env var", () => {
45
48
  const apiKeyRef: ApiKeyRef = { current: "" };
49
+ const assistantIdRef: AssistantIdRef = { current: "ast_abc123" };
46
50
  const { getAssistantApiKey } = buildLazyGetters({
47
51
  platformBaseUrl: "https://api.vellum.ai",
48
- assistantId: "ast_abc123",
52
+ assistantIdRef,
49
53
  apiKeyRef,
50
54
  });
51
55
 
@@ -60,9 +64,10 @@ describe("managed lazy getters — before API key arrives", () => {
60
64
  describe("managed lazy getters — after API key arrives via handshake", () => {
61
65
  test("setting apiKeyRef.current enables managed subject options", () => {
62
66
  const apiKeyRef: ApiKeyRef = { current: "" };
67
+ const assistantIdRef: AssistantIdRef = { current: "ast_abc123" };
63
68
  const { getManagedSubjectOptions } = buildLazyGetters({
64
69
  platformBaseUrl: "https://api.vellum.ai",
65
- assistantId: "ast_abc123",
70
+ assistantIdRef,
66
71
  apiKeyRef,
67
72
  });
68
73
 
@@ -79,9 +84,10 @@ describe("managed lazy getters — after API key arrives via handshake", () => {
79
84
 
80
85
  test("setting apiKeyRef.current enables managed materializer options", () => {
81
86
  const apiKeyRef: ApiKeyRef = { current: "" };
87
+ const assistantIdRef: AssistantIdRef = { current: "ast_abc123" };
82
88
  const { getManagedMaterializerOptions } = buildLazyGetters({
83
89
  platformBaseUrl: "https://api.vellum.ai",
84
- assistantId: "ast_abc123",
90
+ assistantIdRef,
85
91
  apiKeyRef,
86
92
  });
87
93
 
@@ -98,10 +104,11 @@ describe("managed lazy getters — after API key arrives via handshake", () => {
98
104
 
99
105
  test("returned options contain the exact key from the ref (not a stale copy)", () => {
100
106
  const apiKeyRef: ApiKeyRef = { current: "" };
107
+ const assistantIdRef: AssistantIdRef = { current: "ast_abc123" };
101
108
  const { getManagedSubjectOptions, getManagedMaterializerOptions } =
102
109
  buildLazyGetters({
103
110
  platformBaseUrl: "https://api.vellum.ai",
104
- assistantId: "ast_abc123",
111
+ assistantIdRef,
105
112
  apiKeyRef,
106
113
  });
107
114
 
@@ -122,11 +129,12 @@ describe("managed lazy getters — after API key arrives via handshake", () => {
122
129
  describe("managed lazy getters — lazy resolution timing", () => {
123
130
  test("handlers built before key arrives resolve the key at call time", () => {
124
131
  const apiKeyRef: ApiKeyRef = { current: "" };
132
+ const assistantIdRef: AssistantIdRef = { current: "ast_abc123" };
125
133
 
126
134
  const { getManagedSubjectOptions, getManagedMaterializerOptions } =
127
135
  buildLazyGetters({
128
136
  platformBaseUrl: "https://api.vellum.ai",
129
- assistantId: "ast_abc123",
137
+ assistantIdRef,
130
138
  apiKeyRef,
131
139
  });
132
140
 
@@ -147,10 +155,11 @@ describe("managed lazy getters — lazy resolution timing", () => {
147
155
 
148
156
  test("deps object with getter properties resolves lazily (mirrors httpDeps pattern)", () => {
149
157
  const apiKeyRef: ApiKeyRef = { current: "" };
158
+ const assistantIdRef: AssistantIdRef = { current: "ast_abc123" };
150
159
  const { getManagedSubjectOptions, getManagedMaterializerOptions } =
151
160
  buildLazyGetters({
152
161
  platformBaseUrl: "https://api.vellum.ai",
153
- assistantId: "ast_abc123",
162
+ assistantIdRef,
154
163
  apiKeyRef,
155
164
  });
156
165
 
@@ -182,9 +191,10 @@ describe("managed lazy getters — lazy resolution timing", () => {
182
191
 
183
192
  test("env var fallback is used when ref is empty", () => {
184
193
  const apiKeyRef: ApiKeyRef = { current: "" };
194
+ const assistantIdRef: AssistantIdRef = { current: "ast_abc123" };
185
195
  const { getAssistantApiKey, getManagedSubjectOptions } = buildLazyGetters({
186
196
  platformBaseUrl: "https://api.vellum.ai",
187
- assistantId: "ast_abc123",
197
+ assistantIdRef,
188
198
  apiKeyRef,
189
199
  envApiKey: "vak_env_fallback",
190
200
  });
@@ -198,9 +208,10 @@ describe("managed lazy getters — lazy resolution timing", () => {
198
208
 
199
209
  test("handshake-provided key takes precedence over env var", () => {
200
210
  const apiKeyRef: ApiKeyRef = { current: "" };
211
+ const assistantIdRef: AssistantIdRef = { current: "ast_abc123" };
201
212
  const { getAssistantApiKey } = buildLazyGetters({
202
213
  platformBaseUrl: "https://api.vellum.ai",
203
- assistantId: "ast_abc123",
214
+ assistantIdRef,
204
215
  apiKeyRef,
205
216
  envApiKey: "vak_env_key",
206
217
  });
@@ -219,10 +230,11 @@ describe("managed lazy getters — lazy resolution timing", () => {
219
230
  describe("managed lazy getters — missing platform config fields", () => {
220
231
  test("missing platformBaseUrl returns undefined even with API key", () => {
221
232
  const apiKeyRef: ApiKeyRef = { current: "vak_test_key" };
233
+ const assistantIdRef: AssistantIdRef = { current: "ast_abc123" };
222
234
  const { getManagedSubjectOptions, getManagedMaterializerOptions } =
223
235
  buildLazyGetters({
224
236
  platformBaseUrl: "",
225
- assistantId: "ast_abc123",
237
+ assistantIdRef,
226
238
  apiKeyRef,
227
239
  });
228
240
 
@@ -232,14 +244,51 @@ describe("managed lazy getters — missing platform config fields", () => {
232
244
 
233
245
  test("missing assistantId returns undefined even with API key", () => {
234
246
  const apiKeyRef: ApiKeyRef = { current: "vak_test_key" };
247
+ const assistantIdRef: AssistantIdRef = { current: "" };
235
248
  const { getManagedSubjectOptions, getManagedMaterializerOptions } =
236
249
  buildLazyGetters({
237
250
  platformBaseUrl: "https://api.vellum.ai",
238
- assistantId: "",
251
+ assistantIdRef,
239
252
  apiKeyRef,
240
253
  });
241
254
 
242
255
  expect(getManagedSubjectOptions()).toBeUndefined();
243
256
  expect(getManagedMaterializerOptions()).toBeUndefined();
244
257
  });
258
+
259
+ test("assistantIdRef updated after build enables options (warm-pool scenario)", () => {
260
+ /**
261
+ * Verifies that updating assistantIdRef.current after buildLazyGetters
262
+ * makes previously-undefined options become defined — the core fix for
263
+ * warm-pool pods where PLATFORM_ASSISTANT_ID is empty at CES startup.
264
+ */
265
+
266
+ // GIVEN an API key is available but assistant ID is empty (warm-pool startup)
267
+ const apiKeyRef: ApiKeyRef = { current: "vak_test_key" };
268
+ const assistantIdRef: AssistantIdRef = { current: "" };
269
+ const { getManagedSubjectOptions, getManagedMaterializerOptions } =
270
+ buildLazyGetters({
271
+ platformBaseUrl: "https://api.vellum.ai",
272
+ assistantIdRef,
273
+ apiKeyRef,
274
+ });
275
+
276
+ // WHEN options are checked before assistant ID arrives
277
+ // THEN they are undefined
278
+ expect(getManagedSubjectOptions()).toBeUndefined();
279
+ expect(getManagedMaterializerOptions()).toBeUndefined();
280
+
281
+ // WHEN the assistant ID arrives via handshake/RPC
282
+ assistantIdRef.current = "ast_provisioned_123";
283
+
284
+ // THEN the same getter functions now return valid options
285
+ const subOpts = getManagedSubjectOptions();
286
+ expect(subOpts).toBeDefined();
287
+ expect(subOpts!.assistantId).toBe("ast_provisioned_123");
288
+ expect(subOpts!.assistantApiKey).toBe("vak_test_key");
289
+
290
+ const matOpts = getManagedMaterializerOptions();
291
+ expect(matOpts).toBeDefined();
292
+ expect(matOpts!.assistantId).toBe("ast_provisioned_123");
293
+ });
245
294
  });
@@ -22,9 +22,19 @@ export interface ApiKeyRef {
22
22
  current: string;
23
23
  }
24
24
 
25
+ /**
26
+ * Mutable reference to the platform assistant ID. For warm-pool pods the
27
+ * PLATFORM_ASSISTANT_ID env var is empty at startup; the assistant forwards
28
+ * the ID via the handshake or update_managed_credential RPC after
29
+ * provisioning, and `.current` is updated so lazy getters pick it up.
30
+ */
31
+ export interface AssistantIdRef {
32
+ current: string;
33
+ }
34
+
25
35
  export interface LazyGetterOptions {
26
36
  platformBaseUrl: string;
27
- assistantId: string;
37
+ assistantIdRef: AssistantIdRef;
28
38
  apiKeyRef: ApiKeyRef;
29
39
  envApiKey?: string;
30
40
  }
@@ -43,22 +53,24 @@ export interface LazyGetters {
43
53
  * (chicken-and-egg: key is provisioned after hatch).
44
54
  */
45
55
  export function buildLazyGetters(opts: LazyGetterOptions): LazyGetters {
46
- const { platformBaseUrl, assistantId, apiKeyRef, envApiKey } = opts;
56
+ const { platformBaseUrl, assistantIdRef, apiKeyRef, envApiKey } = opts;
47
57
 
48
58
  const getAssistantApiKey = (): string =>
49
59
  apiKeyRef.current || envApiKey || "";
50
60
 
51
61
  const getManagedSubjectOptions = (): ManagedSubjectResolverOptions | undefined => {
52
62
  const key = getAssistantApiKey();
53
- return platformBaseUrl && key && assistantId
54
- ? { platformBaseUrl, assistantApiKey: key, assistantId }
63
+ const id = assistantIdRef.current;
64
+ return platformBaseUrl && key && id
65
+ ? { platformBaseUrl, assistantApiKey: key, assistantId: id }
55
66
  : undefined;
56
67
  };
57
68
 
58
69
  const getManagedMaterializerOptions = (): ManagedMaterializerOptions | undefined => {
59
70
  const key = getAssistantApiKey();
60
- return platformBaseUrl && key && assistantId
61
- ? { platformBaseUrl, assistantApiKey: key, assistantId }
71
+ const id = assistantIdRef.current;
72
+ return platformBaseUrl && key && id
73
+ ? { platformBaseUrl, assistantApiKey: key, assistantId: id }
62
74
  : undefined;
63
75
  };
64
76
 
@@ -55,7 +55,7 @@ import { buildCesEgressHooks } from "./commands/egress-hooks.js";
55
55
  import { resolveManagedSubject } from "./subjects/managed.js";
56
56
  import { materializeManagedToken } from "./materializers/managed-platform.js";
57
57
  import { HandleType, parseHandle } from "@vellumai/ces-contracts";
58
- import { buildLazyGetters, type ApiKeyRef } from "./managed-lazy-getters.js";
58
+ import { buildLazyGetters, type ApiKeyRef, type AssistantIdRef } from "./managed-lazy-getters.js";
59
59
  import { MANAGED_LOCAL_STATIC_REJECTION_ERROR } from "./managed-errors.js";
60
60
  import type { SecureKeyBackend } from "@vellumai/credential-storage";
61
61
  import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
@@ -91,7 +91,7 @@ function ensureDataDirs(): void {
91
91
  // Build RPC handler registry (managed mode)
92
92
  // ---------------------------------------------------------------------------
93
93
 
94
- function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef, secureKeyBackend: SecureKeyBackend): RpcHandlerRegistry {
94
+ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef, assistantIdRef: AssistantIdRef, secureKeyBackend: SecureKeyBackend): RpcHandlerRegistry {
95
95
  // -- Grant stores ----------------------------------------------------------
96
96
  const persistentGrantStore = new PersistentGrantStore(
97
97
  getCesGrantsDir("managed"),
@@ -112,20 +112,19 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef, secureK
112
112
  // We use a lazy getter so the handshake-provided key takes effect even
113
113
  // though handlers are built before the handshake completes.
114
114
  const platformBaseUrl = process.env["VELLUM_PLATFORM_URL"] ?? "";
115
- const assistantId = process.env["PLATFORM_ASSISTANT_ID"] ?? "";
116
115
 
117
116
  const { getAssistantApiKey, getManagedSubjectOptions, getManagedMaterializerOptions } =
118
117
  buildLazyGetters({
119
118
  platformBaseUrl,
120
- assistantId,
119
+ assistantIdRef,
121
120
  apiKeyRef,
122
121
  envApiKey: process.env["ASSISTANT_API_KEY"] || "",
123
122
  });
124
123
 
125
- if (!platformBaseUrl || !assistantId) {
124
+ if (!platformBaseUrl) {
126
125
  warn(
127
- "VELLUM_PLATFORM_URL and/or PLATFORM_ASSISTANT_ID not set. " +
128
- "Managed credential materialisation will depend on the handshake-provided API key.",
126
+ "VELLUM_PLATFORM_URL not set. " +
127
+ "Managed credential materialisation will depend on the handshake-provided values.",
129
128
  );
130
129
  }
131
130
 
@@ -570,7 +569,8 @@ async function main(): Promise<void> {
570
569
  // are available to handlers at call time (after the handshake completes).
571
570
  const sessionIdRef: SessionIdRef = { current: `ces-managed-${Date.now()}` };
572
571
  const apiKeyRef: ApiKeyRef = { current: "" };
573
- const handlers = buildHandlers(sessionIdRef, apiKeyRef, secureKeyBackend);
572
+ const assistantIdRef: AssistantIdRef = { current: process.env["PLATFORM_ASSISTANT_ID"] ?? "" };
573
+ const handlers = buildHandlers(sessionIdRef, apiKeyRef, assistantIdRef, secureKeyBackend);
574
574
 
575
575
  const server = new CesRpcServer({
576
576
  input: connection.readable,
@@ -585,16 +585,24 @@ async function main(): Promise<void> {
585
585
  process.stderr.write(`[ces-managed] ERROR: ${msg} ${args.map(String).join(" ")}\n`),
586
586
  },
587
587
  signal: controller.signal,
588
- onHandshakeComplete: (hsSessionId, hsApiKey) => {
588
+ onHandshakeComplete: (hsSessionId, hsApiKey, hsAssistantId) => {
589
589
  sessionIdRef.current = hsSessionId;
590
590
  if (hsApiKey) {
591
591
  apiKeyRef.current = hsApiKey;
592
592
  log(`Received assistant API key via handshake`);
593
593
  }
594
+ if (hsAssistantId) {
595
+ assistantIdRef.current = hsAssistantId;
596
+ log(`Received assistant ID via handshake`);
597
+ }
594
598
  },
595
- onApiKeyUpdate: (newKey) => {
599
+ onApiKeyUpdate: (newKey, newAssistantId) => {
596
600
  apiKeyRef.current = newKey;
597
601
  log(`Assistant API key updated via RPC`);
602
+ if (newAssistantId) {
603
+ assistantIdRef.current = newAssistantId;
604
+ log(`Assistant ID updated via RPC`);
605
+ }
598
606
  },
599
607
  });
600
608
 
package/src/server.ts CHANGED
@@ -88,10 +88,10 @@ export interface CesServerOptions {
88
88
  logger?: Pick<Console, "log" | "warn" | "error">;
89
89
  /** Optional abort signal to shut down the server. */
90
90
  signal?: AbortSignal;
91
- /** Callback invoked when the handshake completes with the negotiated session ID and optional API key. */
92
- onHandshakeComplete?: (sessionId: string, assistantApiKey?: string) => void;
93
- /** Callback invoked when the assistant pushes an updated API key after hatch. */
94
- onApiKeyUpdate?: (assistantApiKey: string) => void;
91
+ /** Callback invoked when the handshake completes with the negotiated session ID and optional API key / assistant ID. */
92
+ onHandshakeComplete?: (sessionId: string, assistantApiKey?: string, assistantId?: string) => void;
93
+ /** Callback invoked when the assistant pushes an updated API key (and optionally assistant ID) after hatch. */
94
+ onApiKeyUpdate?: (assistantApiKey: string, assistantId?: string) => void;
95
95
  }
96
96
 
97
97
  // ---------------------------------------------------------------------------
@@ -104,7 +104,7 @@ export class CesRpcServer {
104
104
  private readonly handlers: RpcHandlerRegistry;
105
105
  private readonly logger: Pick<Console, "log" | "warn" | "error">;
106
106
  private readonly signal?: AbortSignal;
107
- private readonly onHandshakeComplete?: (sessionId: string, assistantApiKey?: string) => void;
107
+ private readonly onHandshakeComplete?: (sessionId: string, assistantApiKey?: string, assistantId?: string) => void;
108
108
 
109
109
  private handshakeComplete = false;
110
110
  private sessionId: string | null = null;
@@ -123,8 +123,8 @@ export class CesRpcServer {
123
123
  if (options.onApiKeyUpdate) {
124
124
  const onUpdate = options.onApiKeyUpdate;
125
125
  this.handlers[CesRpcMethod.UpdateManagedCredential] = (request: unknown) => {
126
- const { assistantApiKey } = request as { assistantApiKey: string };
127
- onUpdate(assistantApiKey);
126
+ const { assistantApiKey, assistantId } = request as { assistantApiKey: string; assistantId?: string };
127
+ onUpdate(assistantApiKey, assistantId);
128
128
  return { updated: true };
129
129
  };
130
130
  }
@@ -267,7 +267,7 @@ export class CesRpcServer {
267
267
  this.handshakeComplete = true;
268
268
  this.sessionId = req.sessionId;
269
269
  this.logger.log(`[ces-server] Handshake accepted for session ${req.sessionId}`);
270
- this.onHandshakeComplete?.(req.sessionId, req.assistantApiKey);
270
+ this.onHandshakeComplete?.(req.sessionId, req.assistantApiKey, req.assistantId);
271
271
  } else {
272
272
  this.logger.warn(
273
273
  `[ces-server] Handshake rejected: version mismatch (got ${req.protocolVersion}, expected ${CES_PROTOCOL_VERSION})`,