@vellumai/credential-executor 0.5.11 → 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.
@@ -71,19 +71,19 @@ describe("handles", () => {
71
71
 
72
72
  describe("localOAuthHandle", () => {
73
73
  test("constructs the expected format", () => {
74
- expect(localOAuthHandle("integration:google", "conn-123")).toBe(
75
- "local_oauth:integration:google/conn-123",
74
+ expect(localOAuthHandle("google", "conn-123")).toBe(
75
+ "local_oauth:google/conn-123",
76
76
  );
77
77
  });
78
78
 
79
- test("roundtrips with integration: prefix in providerKey", () => {
80
- const raw = localOAuthHandle("integration:slack", "conn-abc");
79
+ test("roundtrips through parseHandle", () => {
80
+ const raw = localOAuthHandle("slack", "conn-abc");
81
81
  const result = parseHandle(raw);
82
82
  expect(result.ok).toBe(true);
83
83
  if (!result.ok) return;
84
84
  expect(result.handle.type).toBe(HandleType.LocalOAuth);
85
85
  if (result.handle.type !== HandleType.LocalOAuth) return;
86
- expect(result.handle.providerKey).toBe("integration:slack");
86
+ expect(result.handle.providerKey).toBe("slack");
87
87
  expect(result.handle.connectionId).toBe("conn-abc");
88
88
  });
89
89
  });
@@ -133,7 +133,7 @@ describe("handles", () => {
133
133
  });
134
134
 
135
135
  test("rejects local_oauth with no slash", () => {
136
- const result = parseHandle("local_oauth:integration:google");
136
+ const result = parseHandle("local_oauth:google");
137
137
  expect(result.ok).toBe(false);
138
138
  });
139
139
 
@@ -150,7 +150,7 @@ describe("handles", () => {
150
150
  ).not.toThrow();
151
151
  expect(() =>
152
152
  CredentialHandleSchema.parse(
153
- "local_oauth:integration:google/conn-1",
153
+ "local_oauth:google/conn-1",
154
154
  ),
155
155
  ).not.toThrow();
156
156
  expect(() =>
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * 2. **Local OAuth** — references a locally persisted OAuth connection.
15
15
  * Format: `local_oauth:<providerKey>/<connectionId>` where providerKey
16
- * uses the existing `integration:*` keys (e.g. `integration:google`).
16
+ * is the bare provider name (e.g. `google`).
17
17
  *
18
18
  * 3. **Managed OAuth** — references an OAuth connection managed by the
19
19
  * platform. Format: `platform_oauth:<connectionId>` where connectionId
@@ -50,7 +50,7 @@ export interface LocalStaticHandle {
50
50
 
51
51
  export interface LocalOAuthHandle {
52
52
  type: typeof HandleType.LocalOAuth;
53
- /** Provider key (e.g. "integration:google", "integration:slack"). */
53
+ /** Provider key (e.g. "google", "slack"). */
54
54
  providerKey: string;
55
55
  /** Connection identifier. */
56
56
  connectionId: string;
@@ -146,8 +146,9 @@ export function parseHandle(raw: string): ParseHandleResult {
146
146
  }
147
147
 
148
148
  case HandleType.LocalOAuth: {
149
- // providerKey may itself contain a colon (e.g. "integration:google"),
150
- // so we split on the *last* "/" to separate providerKey from connectionId.
149
+ // providerKey is typically a bare name (e.g. "google"), but legacy handles
150
+ // may contain a colon (e.g. "integration:google"), so we split on the
151
+ // *last* "/" to separate providerKey from connectionId.
151
152
  const lastSlashIdx = rest.lastIndexOf("/");
152
153
  if (
153
154
  lastSlashIdx === -1 ||
@@ -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
@@ -98,7 +98,7 @@ export interface SecureKeyBackend {
98
98
  export interface OAuthConnectionRecord {
99
99
  /** Unique identifier for this connection. */
100
100
  id: string;
101
- /** Provider key (e.g. "integration:google", "integration:slack"). */
101
+ /** Provider key (e.g. "google", "slack"). */
102
102
  providerKey: string;
103
103
  /** Account identifier (e.g. email address). */
104
104
  accountInfo: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1482,7 +1482,7 @@ describe("executeAuthenticatedCommand — integration: local OAuth", () => {
1482
1482
  });
1483
1483
  addCommandGrant(
1484
1484
  deps.persistentStore,
1485
- "local_oauth:integration:google/conn-123",
1485
+ "local_oauth:google/conn-123",
1486
1486
  digest,
1487
1487
  "list",
1488
1488
  );
@@ -1490,7 +1490,7 @@ describe("executeAuthenticatedCommand — integration: local OAuth", () => {
1490
1490
  const request: ExecuteCommandRequest = {
1491
1491
  bundleDigest: digest,
1492
1492
  profileName: "list",
1493
- credentialHandle: "local_oauth:integration:google/conn-123",
1493
+ credentialHandle: "local_oauth:google/conn-123",
1494
1494
  argv: ["list", "--format", "json"],
1495
1495
  workspaceDir: testWorkspaceDir,
1496
1496
  purpose: "OAuth pipeline test",
@@ -102,7 +102,7 @@ function buildOAuthConnection(
102
102
  ): OAuthConnectionRecord {
103
103
  return {
104
104
  id: overrides.id ?? "conn-uuid-1",
105
- providerKey: overrides.providerKey ?? "integration:google",
105
+ providerKey: overrides.providerKey ?? "google",
106
106
  accountInfo: overrides.accountInfo ?? "user@example.com",
107
107
  grantedScopes: overrides.grantedScopes ?? ["openid", "email"],
108
108
  accessTokenPath: overrides.accessTokenPath ??
@@ -434,7 +434,7 @@ describe("HTTP executor: local OAuth", () => {
434
434
  beforeEach(() => {
435
435
  connection = buildOAuthConnection({
436
436
  id: "conn-google-1",
437
- providerKey: "integration:google",
437
+ providerKey: "google",
438
438
  expiresAt: Date.now() + 3600000, // valid for 1 hour
439
439
  });
440
440
 
@@ -451,7 +451,7 @@ describe("HTTP executor: local OAuth", () => {
451
451
  });
452
452
 
453
453
  test("successful OAuth request with matching grant", async () => {
454
- const handle = localOAuthHandle("integration:google", "conn-google-1");
454
+ const handle = localOAuthHandle("google", "conn-google-1");
455
455
 
456
456
  fixture.persistentStore.add({
457
457
  id: "grant-google-calendar",
@@ -492,7 +492,7 @@ describe("HTTP executor: local OAuth", () => {
492
492
  });
493
493
 
494
494
  test("OAuth token scrubbed from response body", async () => {
495
- const handle = localOAuthHandle("integration:google", "conn-google-1");
495
+ const handle = localOAuthHandle("google", "conn-google-1");
496
496
 
497
497
  fixture.persistentStore.add({
498
498
  id: "grant-google-debug",
@@ -98,7 +98,7 @@ function buildOAuthConnection(
98
98
  ): OAuthConnectionRecord {
99
99
  return {
100
100
  id: overrides.id ?? "conn-uuid-1",
101
- providerKey: overrides.providerKey ?? "integration:google",
101
+ providerKey: overrides.providerKey ?? "google",
102
102
  accountInfo: overrides.accountInfo ?? "user@example.com",
103
103
  grantedScopes: overrides.grantedScopes ?? ["openid", "email"],
104
104
  accessTokenPath: overrides.accessTokenPath ??
@@ -227,10 +227,10 @@ describe("local OAuth subject resolution", () => {
227
227
  test("resolves a valid OAuth handle to an OAuth subject", () => {
228
228
  const conn = buildOAuthConnection({
229
229
  id: "conn-abc",
230
- providerKey: "integration:google",
230
+ providerKey: "google",
231
231
  });
232
232
  const deps = createResolverDeps({ oauthConnections: [conn] });
233
- const handle = localOAuthHandle("integration:google", "conn-abc");
233
+ const handle = localOAuthHandle("google", "conn-abc");
234
234
 
235
235
  const result = resolveLocalSubject(handle, deps);
236
236
 
@@ -239,12 +239,12 @@ describe("local OAuth subject resolution", () => {
239
239
  expect(result.subject.type).toBe(HandleType.LocalOAuth);
240
240
  if (result.subject.type !== HandleType.LocalOAuth) return;
241
241
  expect(result.subject.connection.id).toBe("conn-abc");
242
- expect(result.subject.connection.providerKey).toBe("integration:google");
242
+ expect(result.subject.connection.providerKey).toBe("google");
243
243
  });
244
244
 
245
245
  test("fails when the connection does not exist", () => {
246
246
  const deps = createResolverDeps({ oauthConnections: [] });
247
- const handle = localOAuthHandle("integration:google", "missing-conn");
247
+ const handle = localOAuthHandle("google", "missing-conn");
248
248
 
249
249
  const result = resolveLocalSubject(handle, deps);
250
250
 
@@ -257,19 +257,19 @@ describe("local OAuth subject resolution", () => {
257
257
  test("fails when provider key in handle does not match connection", () => {
258
258
  const conn = buildOAuthConnection({
259
259
  id: "conn-xyz",
260
- providerKey: "integration:slack",
260
+ providerKey: "slack",
261
261
  });
262
262
  const deps = createResolverDeps({ oauthConnections: [conn] });
263
263
  // Handle says google but connection is slack
264
- const handle = localOAuthHandle("integration:google", "conn-xyz");
264
+ const handle = localOAuthHandle("google", "conn-xyz");
265
265
 
266
266
  const result = resolveLocalSubject(handle, deps);
267
267
 
268
268
  expect(result.ok).toBe(false);
269
269
  if (result.ok) return;
270
270
  expect(result.error).toMatch(/providerKey/);
271
- expect(result.error).toMatch(/integration:slack/);
272
- expect(result.error).toMatch(/integration:google/);
271
+ expect(result.error).toMatch(/slack/);
272
+ expect(result.error).toMatch(/google/);
273
273
  });
274
274
  });
275
275
 
@@ -378,13 +378,13 @@ describe("OAuth token materialisation", () => {
378
378
  test("materialises a valid non-expired access token", async () => {
379
379
  const conn = buildOAuthConnection({
380
380
  id: "conn-1",
381
- providerKey: "integration:google",
381
+ providerKey: "google",
382
382
  // Token expires in the future (1 hour from now)
383
383
  expiresAt: Date.now() + 60 * 60 * 1000,
384
384
  hasRefreshToken: true,
385
385
  });
386
386
  const deps = createResolverDeps({ oauthConnections: [conn] });
387
- const handle = localOAuthHandle("integration:google", "conn-1");
387
+ const handle = localOAuthHandle("google", "conn-1");
388
388
 
389
389
  const resolved = resolveLocalSubject(handle, deps);
390
390
  expect(resolved.ok).toBe(true);
@@ -408,11 +408,11 @@ describe("OAuth token materialisation", () => {
408
408
  test("fails when no access token is stored (disconnected connection)", async () => {
409
409
  const conn = buildOAuthConnection({
410
410
  id: "conn-disconnected",
411
- providerKey: "integration:slack",
411
+ providerKey: "slack",
412
412
  hasRefreshToken: false,
413
413
  });
414
414
  const deps = createResolverDeps({ oauthConnections: [conn] });
415
- const handle = localOAuthHandle("integration:slack", "conn-disconnected");
415
+ const handle = localOAuthHandle("slack", "conn-disconnected");
416
416
 
417
417
  const resolved = resolveLocalSubject(handle, deps);
418
418
  expect(resolved.ok).toBe(true);
@@ -435,14 +435,14 @@ describe("OAuth token materialisation", () => {
435
435
  test("fails when token is expired and hasRefreshToken is false", async () => {
436
436
  const conn = buildOAuthConnection({
437
437
  id: "conn-expired-no-refresh",
438
- providerKey: "integration:google",
438
+ providerKey: "google",
439
439
  // Token expired 10 minutes ago
440
440
  expiresAt: Date.now() - 10 * 60 * 1000,
441
441
  hasRefreshToken: false,
442
442
  });
443
443
  const deps = createResolverDeps({ oauthConnections: [conn] });
444
444
  const handle = localOAuthHandle(
445
- "integration:google",
445
+ "google",
446
446
  "conn-expired-no-refresh",
447
447
  );
448
448
 
@@ -469,12 +469,12 @@ describe("OAuth token materialisation", () => {
469
469
  test("materialises a token with null expiresAt (no expiry info)", async () => {
470
470
  const conn = buildOAuthConnection({
471
471
  id: "conn-noexpiry",
472
- providerKey: "integration:github",
472
+ providerKey: "github",
473
473
  expiresAt: null,
474
474
  hasRefreshToken: false,
475
475
  });
476
476
  const deps = createResolverDeps({ oauthConnections: [conn] });
477
- const handle = localOAuthHandle("integration:github", "conn-noexpiry");
477
+ const handle = localOAuthHandle("github", "conn-noexpiry");
478
478
 
479
479
  const resolved = resolveLocalSubject(handle, deps);
480
480
  expect(resolved.ok).toBe(true);
@@ -504,13 +504,13 @@ describe("OAuth refresh-on-expiry", () => {
504
504
  test("refreshes an expired token and returns the new access token", async () => {
505
505
  const conn = buildOAuthConnection({
506
506
  id: "conn-expired",
507
- providerKey: "integration:google",
507
+ providerKey: "google",
508
508
  // Token expired 10 minutes ago
509
509
  expiresAt: Date.now() - 10 * 60 * 1000,
510
510
  hasRefreshToken: true,
511
511
  });
512
512
  const deps = createResolverDeps({ oauthConnections: [conn] });
513
- const handle = localOAuthHandle("integration:google", "conn-expired");
513
+ const handle = localOAuthHandle("google", "conn-expired");
514
514
 
515
515
  const resolved = resolveLocalSubject(handle, deps);
516
516
  expect(resolved.ok).toBe(true);
@@ -545,12 +545,12 @@ describe("OAuth refresh-on-expiry", () => {
545
545
  test("fails when token is expired but no refresh function is configured", async () => {
546
546
  const conn = buildOAuthConnection({
547
547
  id: "conn-no-refresh-fn",
548
- providerKey: "integration:google",
548
+ providerKey: "google",
549
549
  expiresAt: Date.now() - 10 * 60 * 1000,
550
550
  hasRefreshToken: true,
551
551
  });
552
552
  const deps = createResolverDeps({ oauthConnections: [conn] });
553
- const handle = localOAuthHandle("integration:google", "conn-no-refresh-fn");
553
+ const handle = localOAuthHandle("google", "conn-no-refresh-fn");
554
554
 
555
555
  const resolved = resolveLocalSubject(handle, deps);
556
556
  expect(resolved.ok).toBe(true);
@@ -577,13 +577,13 @@ describe("OAuth refresh-on-expiry", () => {
577
577
  test("fails when token is expired and no refresh token is stored", async () => {
578
578
  const conn = buildOAuthConnection({
579
579
  id: "conn-no-stored-refresh",
580
- providerKey: "integration:google",
580
+ providerKey: "google",
581
581
  expiresAt: Date.now() - 10 * 60 * 1000,
582
582
  hasRefreshToken: true,
583
583
  });
584
584
  const deps = createResolverDeps({ oauthConnections: [conn] });
585
585
  const handle = localOAuthHandle(
586
- "integration:google",
586
+ "google",
587
587
  "conn-no-stored-refresh",
588
588
  );
589
589
 
@@ -617,13 +617,13 @@ describe("OAuth refresh-on-expiry", () => {
617
617
  test("fails when refresh function returns a failure result", async () => {
618
618
  const conn = buildOAuthConnection({
619
619
  id: "conn-refresh-fail",
620
- providerKey: "integration:google",
620
+ providerKey: "google",
621
621
  expiresAt: Date.now() - 10 * 60 * 1000,
622
622
  hasRefreshToken: true,
623
623
  });
624
624
  const deps = createResolverDeps({ oauthConnections: [conn] });
625
625
  const handle = localOAuthHandle(
626
- "integration:google",
626
+ "google",
627
627
  "conn-refresh-fail",
628
628
  );
629
629
 
@@ -662,12 +662,12 @@ describe("refresh circuit breaker", () => {
662
662
  test("trips after repeated refresh failures and returns error", async () => {
663
663
  const conn = buildOAuthConnection({
664
664
  id: "conn-breaker",
665
- providerKey: "integration:google",
665
+ providerKey: "google",
666
666
  expiresAt: Date.now() - 10 * 60 * 1000,
667
667
  hasRefreshToken: true,
668
668
  });
669
669
  const deps = createResolverDeps({ oauthConnections: [conn] });
670
- const handle = localOAuthHandle("integration:google", "conn-breaker");
670
+ const handle = localOAuthHandle("google", "conn-breaker");
671
671
 
672
672
  const resolved = resolveLocalSubject(handle, deps);
673
673
  expect(resolved.ok).toBe(true);
@@ -724,7 +724,7 @@ describe("deterministic fail-closed behaviour", () => {
724
724
 
725
725
  // Missing OAuth connection
726
726
  const r3 = resolveLocalSubject(
727
- localOAuthHandle("integration:x", "missing-conn"),
727
+ localOAuthHandle("x", "missing-conn"),
728
728
  deps,
729
729
  );
730
730
  expect(r3.ok).toBe(false);
@@ -765,10 +765,10 @@ describe("deterministic fail-closed behaviour", () => {
765
765
  test("OAuth disconnection detected before any refresh attempt", async () => {
766
766
  const conn = buildOAuthConnection({
767
767
  id: "conn-disco",
768
- providerKey: "integration:slack",
768
+ providerKey: "slack",
769
769
  });
770
770
  const deps = createResolverDeps({ oauthConnections: [conn] });
771
- const handle = localOAuthHandle("integration:slack", "conn-disco");
771
+ const handle = localOAuthHandle("slack", "conn-disco");
772
772
 
773
773
  const resolved = resolveLocalSubject(handle, deps);
774
774
  expect(resolved.ok).toBe(true);
@@ -831,12 +831,12 @@ describe("end-to-end local materialisation", () => {
831
831
  test("full pipeline: resolve OAuth handle -> materialise token", async () => {
832
832
  const conn = buildOAuthConnection({
833
833
  id: "conn-e2e",
834
- providerKey: "integration:linear",
834
+ providerKey: "linear",
835
835
  expiresAt: Date.now() + 3600 * 1000,
836
836
  hasRefreshToken: true,
837
837
  });
838
838
  const deps = createResolverDeps({ oauthConnections: [conn] });
839
- const handle = localOAuthHandle("integration:linear", "conn-e2e");
839
+ const handle = localOAuthHandle("linear", "conn-e2e");
840
840
 
841
841
  // Step 1: Resolve
842
842
  const resolved = resolveLocalSubject(handle, deps);
@@ -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
  });
@@ -264,7 +264,7 @@ describe("resolveManagedSubject", () => {
264
264
 
265
265
  test("rejects a local_oauth handle", async () => {
266
266
  const result = await resolveManagedSubject(
267
- "local_oauth:integration:google/conn_local1",
267
+ "local_oauth:google/conn_local1",
268
268
  {
269
269
  platformBaseUrl: TEST_PLATFORM_URL,
270
270
  assistantApiKey: TEST_API_KEY,
@@ -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})`,