@vellumai/credential-executor 0.8.4 → 0.8.6

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.
@@ -18,10 +18,10 @@
18
18
  */
19
19
 
20
20
  import { afterEach, describe, expect, test } from "bun:test";
21
- import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
21
+ import { mkdirSync, mkdtempSync, rmSync, unlinkSync } from "node:fs";
22
22
  import { tmpdir } from "node:os";
23
23
  import { join } from "node:path";
24
- import { createConnection, type Socket } from "node:net";
24
+ import { createConnection, createServer as createNetServer, type Socket } from "node:net";
25
25
  import { Readable, Writable } from "node:stream";
26
26
 
27
27
  import {
@@ -37,13 +37,12 @@ import {
37
37
  } from "@vellumai/service-contracts/credential-rpc";
38
38
 
39
39
  import { PersistentGrantStore } from "../grants/persistent-store.js";
40
- import { TemporaryGrantStore } from "../grants/temporary-store.js";
41
40
  import { AuditStore } from "../audit/store.js";
42
41
  import {
43
42
  createListGrantsHandler,
44
43
  createListAuditRecordsHandler,
45
44
  } from "../grants/rpc-handlers.js";
46
- import { CesRpcServer, type RpcHandlerRegistry, type SessionIdRef } from "../server.js";
45
+ import { CesRpcServer, type RpcHandlerRegistry, type ServeEndReason, type SessionIdRef } from "../server.js";
47
46
  import { createLocalSecureKeyBackend } from "../materializers/local-secure-key-backend.js";
48
47
 
49
48
  // ---------------------------------------------------------------------------
@@ -159,12 +158,11 @@ function acceptOneConnection(socketPath: string, signal: AbortSignal): Promise<{
159
158
  socket: Socket;
160
159
  }> {
161
160
  return new Promise((resolve, reject) => {
162
- const { createServer: createNetServer } = require("node:net");
163
161
  const netServer = createNetServer();
164
162
 
165
163
  const cleanup = () => {
166
164
  netServer.close();
167
- try { require("node:fs").unlinkSync(socketPath); } catch { /* ok */ }
165
+ try { unlinkSync(socketPath); } catch { /* ok */ }
168
166
  };
169
167
 
170
168
  if (signal.aborted) {
@@ -188,7 +186,7 @@ function acceptOneConnection(socketPath: string, signal: AbortSignal): Promise<{
188
186
 
189
187
  netServer.on("connection", (sock: Socket) => {
190
188
  netServer.close();
191
- try { require("node:fs").unlinkSync(socketPath); } catch { /* ok */ }
189
+ try { unlinkSync(socketPath); } catch { /* ok */ }
192
190
 
193
191
  const readable = new Readable({ read() {} });
194
192
  const writable = new Writable({
@@ -356,9 +354,10 @@ describe("managed CES integration (real Unix socket)", () => {
356
354
  // -- Pick a free port for health server ------------------------------------
357
355
  // Use port 0 trick: bind, read the port, close, then use it.
358
356
  const healthPort = await new Promise<number>((resolve) => {
359
- const srv = require("node:net").createServer();
357
+ const srv = createNetServer();
360
358
  srv.listen(0, () => {
361
- const port = srv.address().port;
359
+ const address = srv.address();
360
+ const port = typeof address === "object" && address ? address.port : 0;
362
361
  srv.close(() => resolve(port));
363
362
  });
364
363
  });
@@ -681,7 +680,7 @@ describe("credential CRUD RPC", () => {
681
680
  */
682
681
  async function setupCredentialRpc(): Promise<{
683
682
  clientSock: Socket;
684
- servePromise: Promise<void>;
683
+ servePromise: Promise<ServeEndReason>;
685
684
  }> {
686
685
  savedEnv = saveEnv();
687
686
  tmpDir = mkdtempSync(join(tmpdir(), "ces-cred-integ-"));
@@ -9,11 +9,76 @@
9
9
  import { describe, expect, test } from "bun:test";
10
10
 
11
11
  import {
12
+ applyManagedCredentialRefs,
12
13
  buildLazyGetters,
13
14
  type ApiKeyRef,
14
15
  type AssistantIdRef,
15
16
  } from "../managed-lazy-getters.js";
16
17
 
18
+ // ---------------------------------------------------------------------------
19
+ // applyManagedCredentialRefs — fail-closed overwrite across sessions
20
+ // ---------------------------------------------------------------------------
21
+
22
+ describe("applyManagedCredentialRefs", () => {
23
+ test("overwrites both refs with the provided values", () => {
24
+ const apiKeyRef: ApiKeyRef = { current: "old-key" };
25
+ const assistantIdRef: AssistantIdRef = { current: "ast_old" };
26
+
27
+ applyManagedCredentialRefs(apiKeyRef, assistantIdRef, "new-key", "ast_new");
28
+
29
+ expect(apiKeyRef.current).toBe("new-key");
30
+ expect(assistantIdRef.current).toBe("ast_new");
31
+ });
32
+
33
+ test("clears a stale assistant ID when the new value is omitted", () => {
34
+ // A new session (or an API-key-only update) that does not carry an
35
+ // assistant ID must not inherit the previous session's ID.
36
+ const apiKeyRef: ApiKeyRef = { current: "prev-key" };
37
+ const assistantIdRef: AssistantIdRef = { current: "ast_prev" };
38
+
39
+ applyManagedCredentialRefs(
40
+ apiKeyRef,
41
+ assistantIdRef,
42
+ "rotated-key",
43
+ undefined,
44
+ );
45
+
46
+ expect(apiKeyRef.current).toBe("rotated-key");
47
+ expect(assistantIdRef.current).toBe("");
48
+ });
49
+
50
+ test("clears a stale API key when the new value is omitted", () => {
51
+ const apiKeyRef: ApiKeyRef = { current: "prev-key" };
52
+ const assistantIdRef: AssistantIdRef = { current: "ast_prev" };
53
+
54
+ applyManagedCredentialRefs(apiKeyRef, assistantIdRef, undefined, undefined);
55
+
56
+ expect(apiKeyRef.current).toBe("");
57
+ expect(assistantIdRef.current).toBe("");
58
+ });
59
+
60
+ test("lazy getters fail closed after a reconnect that omits the ID", () => {
61
+ const apiKeyRef: ApiKeyRef = { current: "session1-key" };
62
+ const assistantIdRef: AssistantIdRef = { current: "ast_session1" };
63
+ const { getManagedMaterializerOptions } = buildLazyGetters({
64
+ platformBaseUrl: "https://api.vellum.ai",
65
+ assistantIdRef,
66
+ apiKeyRef,
67
+ });
68
+
69
+ // A reconnecting session provides a key but no assistant ID.
70
+ applyManagedCredentialRefs(
71
+ apiKeyRef,
72
+ assistantIdRef,
73
+ "session2-key",
74
+ undefined,
75
+ );
76
+
77
+ // Materialization must not proceed with the prior session's assistant ID.
78
+ expect(getManagedMaterializerOptions()).toBeUndefined();
79
+ });
80
+ });
81
+
17
82
  // ---------------------------------------------------------------------------
18
83
  // Before API key arrives
19
84
  // ---------------------------------------------------------------------------
@@ -19,13 +19,11 @@ import { platformOAuthHandle } from "@vellumai/service-contracts/credential-rpc"
19
19
  import {
20
20
  type ManagedSubject,
21
21
  resolveManagedSubject,
22
- SubjectResolutionError,
23
22
  type PlatformCatalogEntry,
24
23
  type ResolvedSubject,
25
24
  } from "../subjects/managed.js";
26
25
  import {
27
26
  materializeManagedToken,
28
- MaterializationError,
29
27
  type ManagedMaterializerOptions,
30
28
  } from "../materializers/managed-platform.js";
31
29
 
@@ -82,7 +80,7 @@ function createMockFetch(handlers: {
82
80
  error?: Error;
83
81
  };
84
82
  }): typeof globalThis.fetch {
85
- return (async (input: RequestInfo | URL, init?: RequestInit) => {
83
+ return (async (input: RequestInfo | URL, _init?: RequestInit) => {
86
84
  const url = typeof input === "string" ? input : input.toString();
87
85
 
88
86
  if (url.includes("/oauth/managed/catalog")) {
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Managed CES reconnection test (real entrypoint subprocess).
3
+ *
4
+ * Spawns the actual `managed-main.ts` entrypoint and verifies that the CES
5
+ * sidecar survives the assistant disconnecting and accepts a reconnection,
6
+ * rather than shutting down when the RPC stream ends.
7
+ *
8
+ * This guards the core invariant that CES runs independently of whether the
9
+ * assistant is actively connected: the assistant container can crash and be
10
+ * restarted (Kubernetes restarts containers, not the whole pod), and the
11
+ * restarted assistant must be able to reconnect to a still-running CES.
12
+ */
13
+
14
+ import { afterEach, describe, expect, test } from "bun:test";
15
+ import { createConnection, createServer, type Socket } from "node:net";
16
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join, resolve } from "node:path";
19
+
20
+ import {
21
+ CES_PROTOCOL_VERSION,
22
+ type HandshakeAck,
23
+ } from "@vellumai/service-contracts/credential-rpc";
24
+
25
+ import type { Subprocess } from "bun";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** Sleep for the given number of milliseconds. */
32
+ function delay(ms: number): Promise<void> {
33
+ return new Promise((r) => setTimeout(r, ms));
34
+ }
35
+
36
+ /** Pick a currently-free TCP port by binding to port 0 and reading it back. */
37
+ function pickFreePort(): Promise<number> {
38
+ return new Promise((resolve, reject) => {
39
+ const srv = createServer();
40
+ srv.listen(0, () => {
41
+ const address = srv.address();
42
+ const port = typeof address === "object" && address ? address.port : 0;
43
+ srv.close(() => (port ? resolve(port) : reject(new Error("no port"))));
44
+ });
45
+ srv.on("error", reject);
46
+ });
47
+ }
48
+
49
+ /** Poll the health endpoint until it responds OK or the deadline passes. */
50
+ async function waitForHealth(port: number, timeoutMs = 15_000): Promise<void> {
51
+ const deadline = Date.now() + timeoutMs;
52
+ while (Date.now() < deadline) {
53
+ try {
54
+ const resp = await fetch(`http://127.0.0.1:${port}/healthz`);
55
+ if (resp.ok) return;
56
+ } catch {
57
+ // not up yet
58
+ }
59
+ await delay(100);
60
+ }
61
+ throw new Error(`CES health endpoint did not come up within ${timeoutMs}ms`);
62
+ }
63
+
64
+ /** Read the `rpcConnected` field from /readyz. */
65
+ async function readyzRpcConnected(port: number): Promise<boolean> {
66
+ const resp = await fetch(`http://127.0.0.1:${port}/readyz`);
67
+ const body = (await resp.json()) as { rpcConnected?: boolean };
68
+ return body.rpcConnected === true;
69
+ }
70
+
71
+ /** Wait until the socket path exists (CES has bound the bootstrap socket). */
72
+ async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise<void> {
73
+ const deadline = Date.now() + timeoutMs;
74
+ while (Date.now() < deadline) {
75
+ if (existsSync(socketPath)) return;
76
+ await delay(50);
77
+ }
78
+ throw new Error(`Bootstrap socket did not appear within ${timeoutMs}ms`);
79
+ }
80
+
81
+ /** Connect to the bootstrap socket, retrying past the listen/connect race. */
82
+ function connectToSocket(
83
+ socketPath: string,
84
+ { maxRetries = 40, baseDelayMs = 25 } = {},
85
+ ): Promise<Socket> {
86
+ return new Promise((resolveConn, reject) => {
87
+ let attempt = 0;
88
+ const tryConnect = () => {
89
+ const sock = createConnection(socketPath, () => {
90
+ sock.removeAllListeners("error");
91
+ resolveConn(sock);
92
+ });
93
+ sock.on("error", (err: NodeJS.ErrnoException) => {
94
+ sock.destroy();
95
+ attempt++;
96
+ if (
97
+ attempt < maxRetries &&
98
+ (err.code === "ENOENT" || err.code === "ECONNREFUSED")
99
+ ) {
100
+ setTimeout(tryConnect, baseDelayMs);
101
+ } else {
102
+ reject(err);
103
+ }
104
+ });
105
+ };
106
+ tryConnect();
107
+ });
108
+ }
109
+
110
+ /** Send a handshake and resolve the resulting ack. */
111
+ function handshake(sock: Socket, sessionId: string): Promise<HandshakeAck> {
112
+ return new Promise((resolveAck, reject) => {
113
+ let buffer = "";
114
+ const timer = setTimeout(() => {
115
+ sock.removeAllListeners("data");
116
+ reject(new Error("Timed out waiting for handshake ack"));
117
+ }, 5_000);
118
+
119
+ sock.on("data", (chunk: Buffer) => {
120
+ buffer += chunk.toString("utf-8");
121
+ const idx = buffer.indexOf("\n");
122
+ if (idx === -1) return;
123
+ const line = buffer.slice(0, idx).trim();
124
+ clearTimeout(timer);
125
+ sock.removeAllListeners("data");
126
+ try {
127
+ resolveAck(JSON.parse(line) as HandshakeAck);
128
+ } catch (err) {
129
+ reject(err as Error);
130
+ }
131
+ });
132
+ sock.on("error", (err) => {
133
+ clearTimeout(timer);
134
+ reject(err);
135
+ });
136
+
137
+ sock.write(
138
+ JSON.stringify({
139
+ type: "handshake_request",
140
+ protocolVersion: CES_PROTOCOL_VERSION,
141
+ sessionId,
142
+ }) + "\n",
143
+ );
144
+ });
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Test
149
+ // ---------------------------------------------------------------------------
150
+
151
+ let tmpDir: string | undefined;
152
+ let proc: Subprocess | undefined;
153
+
154
+ afterEach(async () => {
155
+ if (proc) {
156
+ proc.kill("SIGTERM");
157
+ await Promise.race([proc.exited, delay(3_000)]);
158
+ proc = undefined;
159
+ }
160
+ if (tmpDir) {
161
+ try {
162
+ rmSync(tmpDir, { recursive: true, force: true });
163
+ } catch {
164
+ /* ok */
165
+ }
166
+ tmpDir = undefined;
167
+ }
168
+ });
169
+
170
+ describe("managed CES reconnection (real entrypoint)", () => {
171
+ test("survives an assistant disconnect and accepts a reconnection", async () => {
172
+ tmpDir = mkdtempSync(join(tmpdir(), "ces-reconnect-"));
173
+ const dataDir = join(tmpDir, "ces-data");
174
+ const socketDir = join(tmpDir, "bootstrap");
175
+ const socketPath = join(socketDir, "ces.sock");
176
+ const assistantDataMount = join(tmpDir, "assistant-data-ro");
177
+ mkdirSync(dataDir, { recursive: true });
178
+ mkdirSync(socketDir, { recursive: true });
179
+ mkdirSync(join(assistantDataMount, ".vellum"), { recursive: true });
180
+
181
+ const healthPort = await pickFreePort();
182
+ const managedMain = resolve(__dirname, "..", "managed-main.ts");
183
+
184
+ proc = Bun.spawn({
185
+ cmd: [process.execPath, managedMain],
186
+ env: {
187
+ ...process.env,
188
+ CES_MODE: "managed",
189
+ CES_DATA_DIR: dataDir,
190
+ CES_BOOTSTRAP_SOCKET: socketPath,
191
+ CES_HEALTH_PORT: String(healthPort),
192
+ CES_ASSISTANT_DATA_MOUNT: assistantDataMount,
193
+ },
194
+ stdout: "ignore",
195
+ stderr: "ignore",
196
+ });
197
+
198
+ // Sidecar comes up and binds the bootstrap socket.
199
+ await waitForHealth(healthPort);
200
+ await waitForSocket(socketPath);
201
+ expect(await readyzRpcConnected(healthPort)).toBe(false);
202
+
203
+ // First assistant session connects and completes a handshake.
204
+ const first = await connectToSocket(socketPath);
205
+ const ack1 = await handshake(first, "session-1");
206
+ expect(ack1.accepted).toBe(true);
207
+
208
+ // Give /readyz a moment to flip, then confirm CES sees the connection.
209
+ await delay(200);
210
+ expect(await readyzRpcConnected(healthPort)).toBe(true);
211
+
212
+ // Simulate the assistant pod crashing: drop the connection hard.
213
+ first.destroy();
214
+
215
+ // CES must NOT exit. It should stay healthy, flip rpcConnected back to
216
+ // false, and re-bind the bootstrap socket to await a reconnection.
217
+ await waitForSocket(socketPath);
218
+ expect(proc.killed).toBe(false);
219
+ const resp = await fetch(`http://127.0.0.1:${healthPort}/healthz`);
220
+ expect(resp.ok).toBe(true);
221
+
222
+ // Wait for the new session's rpcConnected to clear before reconnecting.
223
+ const cleared = await (async () => {
224
+ const deadline = Date.now() + 5_000;
225
+ while (Date.now() < deadline) {
226
+ if (!(await readyzRpcConnected(healthPort))) return true;
227
+ await delay(100);
228
+ }
229
+ return false;
230
+ })();
231
+ expect(cleared).toBe(true);
232
+
233
+ // The restarted assistant reconnects and handshakes successfully.
234
+ const second = await connectToSocket(socketPath);
235
+ const ack2 = await handshake(second, "session-2");
236
+ expect(ack2.accepted).toBe(true);
237
+ expect(ack2.sessionId).toBe("session-2");
238
+
239
+ await delay(200);
240
+ expect(await readyzRpcConnected(healthPort)).toBe(true);
241
+
242
+ second.destroy();
243
+ }, 30_000);
244
+ });
@@ -4,6 +4,7 @@ import {
4
4
  rmSync,
5
5
  existsSync,
6
6
  readFileSync,
7
+ readdirSync,
7
8
  writeFileSync,
8
9
  symlinkSync,
9
10
  } from "node:fs";
@@ -395,7 +396,6 @@ describe("publishBundle — digest mismatch rejection", () => {
395
396
 
396
397
  // Also check that no staging directories were left behind
397
398
  if (existsSync(toolstoreDir)) {
398
- const { readdirSync } = require("node:fs");
399
399
  const entries = readdirSync(toolstoreDir) as string[];
400
400
  const stagingDirs = entries.filter((e: string) =>
401
401
  e.startsWith(".staging-"),
@@ -160,7 +160,7 @@ describe("health probes", () => {
160
160
  expect(src).toMatch(/\/healthz/);
161
161
  expect(src).toMatch(/\/readyz/);
162
162
  // Health server uses Bun.serve on a dedicated port, not the socket
163
- expect(src).toMatch(/startHealthServer\(healthPort/);
163
+ expect(src).toMatch(/startHealthServer\(\s*healthPort/);
164
164
  });
165
165
 
166
166
  test("getHealthPort defaults to 8090", () => {
@@ -221,7 +221,7 @@ describe("local entrypoint transport isolation", () => {
221
221
  describe("CesRpcServer", () => {
222
222
  test("completes handshake with correct protocol version", async () => {
223
223
  const { server, handshake, input } = createTestServer();
224
- const servePromise = server.serve();
224
+ const _servePromise = server.serve();
225
225
 
226
226
  const ack = await handshake();
227
227
  expect(ack.type).toBe("handshake_ack");
@@ -237,7 +237,7 @@ describe("CesRpcServer", () => {
237
237
 
238
238
  test("rejects handshake with wrong protocol version", async () => {
239
239
  const { server, send, collectOutputLines, input } = createTestServer();
240
- const servePromise = server.serve();
240
+ const _servePromise = server.serve();
241
241
 
242
242
  send({
243
243
  type: "handshake_request",
@@ -259,7 +259,7 @@ describe("CesRpcServer", () => {
259
259
 
260
260
  test("rejects RPC before handshake", async () => {
261
261
  const { server, send, collectOutputLines, input } = createTestServer();
262
- const servePromise = server.serve();
262
+ const _servePromise = server.serve();
263
263
 
264
264
  send({
265
265
  type: "rpc",
@@ -283,13 +283,13 @@ describe("CesRpcServer", () => {
283
283
 
284
284
  test("dispatches RPC to registered handler", async () => {
285
285
  const handlers: RpcHandlerRegistry = {
286
- list_grants: async (req: unknown) => {
286
+ list_grants: async (_req: unknown) => {
287
287
  return { grants: [] };
288
288
  },
289
289
  };
290
290
 
291
291
  const { server, handshake, rpc, input } = createTestServer(handlers);
292
- const servePromise = server.serve();
292
+ const _servePromise = server.serve();
293
293
 
294
294
  await handshake();
295
295
 
@@ -304,7 +304,7 @@ describe("CesRpcServer", () => {
304
304
 
305
305
  test("returns METHOD_NOT_FOUND for unknown methods", async () => {
306
306
  const { server, handshake, rpc, input } = createTestServer();
307
- const servePromise = server.serve();
307
+ const _servePromise = server.serve();
308
308
 
309
309
  await handshake();
310
310
 
@@ -326,7 +326,7 @@ describe("CesRpcServer", () => {
326
326
  };
327
327
 
328
328
  const { server, handshake, rpc, input } = createTestServer(handlers);
329
- const servePromise = server.serve();
329
+ const _servePromise = server.serve();
330
330
 
331
331
  await handshake();
332
332
 
@@ -32,6 +32,27 @@ export interface AssistantIdRef {
32
32
  current: string;
33
33
  }
34
34
 
35
+ /**
36
+ * Overwrite the session-scoped managed credential refs.
37
+ *
38
+ * The managed handler registry is long-lived — it persists across assistant
39
+ * reconnects — so every handshake and every `update_managed_credential` must
40
+ * fully overwrite these refs, including clearing them when a value is absent.
41
+ * Otherwise a new or reprovisioned session could keep materializing platform
42
+ * credentials with the previous session's API key or assistant ID. Absent
43
+ * values fall back to "" (fail closed): the lazy getters then return no
44
+ * materialization options, and `getAssistantApiKey` falls back to the env key.
45
+ */
46
+ export function applyManagedCredentialRefs(
47
+ apiKeyRef: ApiKeyRef,
48
+ assistantIdRef: AssistantIdRef,
49
+ apiKey: string | undefined,
50
+ assistantId: string | undefined,
51
+ ): void {
52
+ apiKeyRef.current = apiKey ?? "";
53
+ assistantIdRef.current = assistantId ?? "";
54
+ }
55
+
35
56
  export interface LazyGetterOptions {
36
57
  platformBaseUrl: string;
37
58
  assistantIdRef: AssistantIdRef;
@@ -55,10 +76,11 @@ export interface LazyGetters {
55
76
  export function buildLazyGetters(opts: LazyGetterOptions): LazyGetters {
56
77
  const { platformBaseUrl, assistantIdRef, apiKeyRef, envApiKey } = opts;
57
78
 
58
- const getAssistantApiKey = (): string =>
59
- apiKeyRef.current || envApiKey || "";
79
+ const getAssistantApiKey = (): string => apiKeyRef.current || envApiKey || "";
60
80
 
61
- const getManagedSubjectOptions = (): ManagedSubjectResolverOptions | undefined => {
81
+ const getManagedSubjectOptions = ():
82
+ | ManagedSubjectResolverOptions
83
+ | undefined => {
62
84
  const key = getAssistantApiKey();
63
85
  const id = assistantIdRef.current;
64
86
  return platformBaseUrl && key && id
@@ -66,7 +88,9 @@ export function buildLazyGetters(opts: LazyGetterOptions): LazyGetters {
66
88
  : undefined;
67
89
  };
68
90
 
69
- const getManagedMaterializerOptions = (): ManagedMaterializerOptions | undefined => {
91
+ const getManagedMaterializerOptions = ():
92
+ | ManagedMaterializerOptions
93
+ | undefined => {
70
94
  const key = getAssistantApiKey();
71
95
  const id = assistantIdRef.current;
72
96
  return platformBaseUrl && key && id