@vellumai/credential-executor 0.11.2-dev.202608060103.4220693 → 0.11.2-dev.202608060508.a7ab7f7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.11.2-dev.202608060103.4220693",
3
+ "version": "0.11.2-dev.202608060508.a7ab7f7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -6,23 +6,38 @@
6
6
  * tests in `local-materializers.test.ts`.
7
7
  */
8
8
 
9
- import { afterEach, describe, expect, test } from "bun:test";
10
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
9
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
10
+ import * as fs from "node:fs";
11
+ import {
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ statSync,
16
+ writeFileSync,
17
+ rmSync,
18
+ } from "node:fs";
11
19
  import { randomBytes } from "node:crypto";
12
20
  import { join } from "node:path";
13
21
  import { tmpdir } from "node:os";
14
22
 
23
+ import { CesRpcMethod } from "@vellumai/service-contracts/credential-rpc";
24
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
25
+
15
26
  import {
16
27
  createLocalSecureKeyBackend,
17
28
  StoreUnavailableError,
18
29
  } from "../materializers/local-secure-key-backend.js";
30
+ import { buildCrudHandlers } from "../main.js";
19
31
 
20
32
  // ---------------------------------------------------------------------------
21
33
  // Helpers
22
34
  // ---------------------------------------------------------------------------
23
35
 
24
36
  function makeTmpDir(): string {
25
- const dir = join(tmpdir(), `ces-backend-test-${randomBytes(8).toString("hex")}`);
37
+ const dir = join(
38
+ tmpdir(),
39
+ `ces-backend-test-${randomBytes(8).toString("hex")}`,
40
+ );
26
41
  mkdirSync(dir, { recursive: true });
27
42
  return dir;
28
43
  }
@@ -100,7 +115,9 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
100
115
  expect(Buffer.compare(keyAfterFirst, keyAfterSecond)).toBe(0);
101
116
 
102
117
  // keys.enc has exactly 2 entries
103
- const store = JSON.parse(readFileSync(join(securityDir, "keys.enc"), "utf-8"));
118
+ const store = JSON.parse(
119
+ readFileSync(join(securityDir, "keys.enc"), "utf-8"),
120
+ );
104
121
  expect(Object.keys(store.entries).length).toBe(2);
105
122
 
106
123
  // Both values round-trip
@@ -114,16 +131,22 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
114
131
  // Manually write a v1 store
115
132
  const salt = randomBytes(32).toString("hex");
116
133
  const v1Store = { version: 1, salt, entries: {} };
117
- writeFileSync(join(securityDir, "keys.enc"), JSON.stringify(v1Store, null, 2), {
118
- mode: 0o600,
119
- });
134
+ writeFileSync(
135
+ join(securityDir, "keys.enc"),
136
+ JSON.stringify(v1Store, null, 2),
137
+ {
138
+ mode: 0o600,
139
+ },
140
+ );
120
141
 
121
142
  const backend = createLocalSecureKeyBackend(vellumRoot);
122
143
  const result = await backend.set("v1-key", "v1-value");
123
144
  expect(result).toBe(true);
124
145
 
125
146
  // Read back and verify format preserved
126
- const storeAfter = JSON.parse(readFileSync(join(securityDir, "keys.enc"), "utf-8"));
147
+ const storeAfter = JSON.parse(
148
+ readFileSync(join(securityDir, "keys.enc"), "utf-8"),
149
+ );
127
150
  expect(storeAfter.version).toBe(1);
128
151
  expect(storeAfter.salt).toBe(salt);
129
152
  expect(Object.keys(storeAfter.entries)).toContain("v1-key");
@@ -173,7 +196,9 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
173
196
  mode: 0o600,
174
197
  });
175
198
  const backend = createLocalSecureKeyBackend(vellumRoot);
176
- await expect(backend.get("anything")).rejects.toThrow(StoreUnavailableError);
199
+ await expect(backend.get("anything")).rejects.toThrow(
200
+ StoreUnavailableError,
201
+ );
177
202
  });
178
203
 
179
204
  test("get() returns undefined when the store reads cleanly but the key is absent", async () => {
@@ -201,4 +226,119 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
201
226
  const backend = createLocalSecureKeyBackend(vellumRoot);
202
227
  await expect(backend.list()).rejects.toThrow(StoreUnavailableError);
203
228
  });
229
+
230
+ // -------------------------------------------------------------------------
231
+ // Store-write durability: the store is written to a temp file with
232
+ // `flush: true` so the ack the assistant receives follows an fsync.
233
+ // -------------------------------------------------------------------------
234
+
235
+ test("writeStore fsyncs the temp file (flush: true) so the ack follows a durable write", async () => {
236
+ const { securityDir, vellumRoot } = setup();
237
+ const spy = spyOn(fs, "writeFileSync");
238
+ try {
239
+ const backend = createLocalSecureKeyBackend(vellumRoot);
240
+ await backend.set("durable/key", "v");
241
+
242
+ const keysEncTmpPrefix = join(securityDir, "keys.enc") + ".tmp.";
243
+ const flushedStoreWrite = spy.mock.calls.some((call) => {
244
+ const [path, , opts] = call as [
245
+ unknown,
246
+ unknown,
247
+ { flush?: boolean } | undefined,
248
+ ];
249
+ return (
250
+ typeof path === "string" &&
251
+ path.startsWith(keysEncTmpPrefix) &&
252
+ !!opts &&
253
+ typeof opts === "object" &&
254
+ opts.flush === true
255
+ );
256
+ });
257
+ expect(flushedStoreWrite).toBe(true);
258
+ } finally {
259
+ spy.mockRestore();
260
+ }
261
+ });
262
+
263
+ test("writeStore fsyncs the parent directory so the rename is durable", async () => {
264
+ const { vellumRoot } = setup();
265
+ const fsyncSpy = spyOn(fs, "fsyncSync");
266
+ try {
267
+ const backend = createLocalSecureKeyBackend(vellumRoot);
268
+ await backend.set("durable/key", "v");
269
+ // The directory fsync runs after the rename; at least one fsyncSync call
270
+ // must have happened for the store write.
271
+ expect(fsyncSpy.mock.calls.length).toBeGreaterThan(0);
272
+ } finally {
273
+ fsyncSpy.mockRestore();
274
+ }
275
+ });
276
+ });
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // CES CRUD audit logging (buildCrudHandlers)
280
+ // ---------------------------------------------------------------------------
281
+
282
+ describe("buildCrudHandlers audit logging", () => {
283
+ function fakeBackend(over: Partial<SecureKeyBackend> = {}): SecureKeyBackend {
284
+ return {
285
+ get: async () => undefined,
286
+ set: async () => true,
287
+ delete: async () => "deleted",
288
+ list: async () => [],
289
+ ...over,
290
+ };
291
+ }
292
+
293
+ const ctx = { sessionId: "test-session" };
294
+
295
+ test("SetCredential emits an audit line with account + outcome, never the value", async () => {
296
+ const calls: Array<{ obj: unknown; msg: string }> = [];
297
+ const audit = {
298
+ info: (obj: unknown, msg: string) => {
299
+ calls.push({ obj, msg });
300
+ },
301
+ };
302
+ const handlers = buildCrudHandlers(
303
+ fakeBackend(),
304
+ audit as unknown as Parameters<typeof buildCrudHandlers>[1],
305
+ );
306
+
307
+ const res = await handlers[CesRpcMethod.SetCredential]!(
308
+ { account: "vellum:assistant_api_key", value: "super-secret-value" },
309
+ ctx,
310
+ );
311
+
312
+ expect(res).toEqual({ ok: true });
313
+ expect(calls).toContainEqual({
314
+ obj: { account: "vellum:assistant_api_key", ok: true },
315
+ msg: "CES credential set",
316
+ });
317
+ // The audit line must never carry the credential value.
318
+ expect(JSON.stringify(calls)).not.toContain("super-secret-value");
319
+ });
320
+
321
+ test("DeleteCredential emits an audit line with account + result", async () => {
322
+ const calls: Array<{ obj: unknown; msg: string }> = [];
323
+ const audit = {
324
+ info: (obj: unknown, msg: string) => {
325
+ calls.push({ obj, msg });
326
+ },
327
+ };
328
+ const handlers = buildCrudHandlers(
329
+ fakeBackend({ delete: async () => "deleted" }),
330
+ audit as unknown as Parameters<typeof buildCrudHandlers>[1],
331
+ );
332
+
333
+ const res = await handlers[CesRpcMethod.DeleteCredential]!(
334
+ { account: "github:api_token" },
335
+ ctx,
336
+ );
337
+
338
+ expect(res).toEqual({ result: "deleted" });
339
+ expect(calls).toContainEqual({
340
+ obj: { account: "github:api_token", result: "deleted" },
341
+ msg: "CES credential delete",
342
+ });
343
+ });
204
344
  });
package/src/main.ts CHANGED
@@ -75,8 +75,9 @@ function ensureDataDirs(mode: CesMode): void {
75
75
  * share the same registry — they differ only in where the secure key backend
76
76
  * reads from and whether the health server is started.
77
77
  */
78
- function buildCrudHandlers(
78
+ export function buildCrudHandlers(
79
79
  secureKeyBackend: SecureKeyBackend,
80
+ audit: Pick<ReturnType<typeof getLogger>, "info"> = log,
80
81
  ): RpcHandlerRegistry {
81
82
  const handlers: RpcHandlerRegistry = {};
82
83
 
@@ -90,6 +91,8 @@ function buildCrudHandlers(
90
91
  value: string;
91
92
  }) => {
92
93
  const ok = await secureKeyBackend.set(req.account, req.value);
94
+ // Audit the mutation: account name and outcome only, never the value.
95
+ audit.info({ account: req.account, ok }, "CES credential set");
93
96
  return { ok };
94
97
  }) as (typeof handlers)[string];
95
98
 
@@ -97,6 +100,7 @@ function buildCrudHandlers(
97
100
  account: string;
98
101
  }) => {
99
102
  const result = await secureKeyBackend.delete(req.account);
103
+ audit.info({ account: req.account, result }, "CES credential delete");
100
104
  return { result };
101
105
  }) as (typeof handlers)[string];
102
106
 
@@ -111,6 +115,7 @@ function buildCrudHandlers(
111
115
  const results = [];
112
116
  for (const { account, value } of req.credentials) {
113
117
  const ok = await secureKeyBackend.set(account, value);
118
+ audit.info({ account, ok }, "CES credential set (bulk)");
114
119
  results.push({ account, ok });
115
120
  }
116
121
  return { results };
@@ -169,6 +174,7 @@ function serveStandaloneSocket(opts: {
169
174
 
170
175
  netServer.on("connection", (socket: Socket) => {
171
176
  connectionCount++;
177
+ log.info({ connections: connectionCount }, "CES client connected");
172
178
  const readable = new Readable({ read() {} });
173
179
  const writable = new Writable({
174
180
  write(chunk, _encoding, callback) {
@@ -205,6 +211,7 @@ function serveStandaloneSocket(opts: {
205
211
  })
206
212
  .then(() => {
207
213
  connectionCount = Math.max(0, connectionCount - 1);
214
+ log.info({ connections: connectionCount }, "CES client disconnected");
208
215
  });
209
216
  });
210
217
 
@@ -392,11 +399,19 @@ async function main(): Promise<void> {
392
399
  log,
393
400
  onApiKeyUpdate:
394
401
  mode === "managed"
395
- ? (_newKey: string, newAssistantId?: string) => {
396
- log.info("Assistant API key updated via RPC");
397
- if (newAssistantId) {
398
- log.info("Assistant ID updated via RPC");
399
- }
402
+ ? (newKey: string, newAssistantId?: string) => {
403
+ // Ack-only. CES reads the durable assistant API key from the shared
404
+ // credential store via the CRUD handlers; this RPC just acknowledges
405
+ // the assistant's notification that the key rotated. The pushed
406
+ // value is intentionally not persisted here (length logged, never
407
+ // the value).
408
+ log.info(
409
+ {
410
+ keyBytes: newKey.length,
411
+ assistantIdUpdated: Boolean(newAssistantId),
412
+ },
413
+ "Acknowledged assistant API key update notification (ack only; value not stored by CES)",
414
+ );
400
415
  }
401
416
  : undefined,
402
417
  });
@@ -413,11 +428,13 @@ async function main(): Promise<void> {
413
428
  log.info("Server stopped.");
414
429
  }
415
430
 
416
- main().catch((err) => {
417
- try {
418
- getLogger("main").fatal({ err }, "Fatal error");
419
- } catch {
420
- process.stderr.write(`[ces-${getCesMode()}] Fatal: ${err}\n`);
421
- }
422
- process.exit(1);
423
- });
431
+ if (import.meta.main) {
432
+ main().catch((err) => {
433
+ try {
434
+ getLogger("main").fatal({ err }, "Fatal error");
435
+ } catch {
436
+ process.stderr.write(`[ces-${getCesMode()}] Fatal: ${err}\n`);
437
+ }
438
+ process.exit(1);
439
+ });
440
+ }
@@ -38,7 +38,17 @@ import {
38
38
  pbkdf2Sync,
39
39
  randomBytes,
40
40
  } from "node:crypto";
41
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
41
+ import {
42
+ chmodSync,
43
+ closeSync,
44
+ existsSync,
45
+ fsyncSync,
46
+ mkdirSync,
47
+ openSync,
48
+ readFileSync,
49
+ renameSync,
50
+ writeFileSync,
51
+ } from "node:fs";
42
52
  import { hostname, userInfo } from "node:os";
43
53
  import { dirname, join } from "node:path";
44
54
 
@@ -55,8 +65,7 @@ const ALGORITHM = "aes-256-gcm";
55
65
  const KEY_LENGTH = 32; // bytes (256 bits)
56
66
  const IV_LENGTH = 16; // bytes (128 bits)
57
67
  const AUTH_TAG_LENGTH = 16; // bytes
58
- const PBKDF2_ITERATIONS =
59
- process.env.BUN_TEST === "1" ? 1 : 100_000;
68
+ const PBKDF2_ITERATIONS = process.env.BUN_TEST === "1" ? 1 : 100_000;
60
69
 
61
70
  // ---------------------------------------------------------------------------
62
71
  // On-disk format (must match assistant/src/security/encrypted-store.ts)
@@ -250,10 +259,30 @@ function writeStore(store: StoreFile, storePath: string): void {
250
259
  const protectedDir = dirname(storePath);
251
260
  mkdirSync(protectedDir, { recursive: true });
252
261
  // Atomic write: write to temp file then rename to avoid partial/corrupt writes.
262
+ // `flush: true` fsyncs the temp file before we return, so the ack the caller
263
+ // receives follows a durable write and survives a crash within the window.
253
264
  const tmpPath = storePath + `.tmp.${process.pid}`;
254
- writeFileSync(tmpPath, JSON.stringify(store, null, 2), { mode: 0o600 });
265
+ writeFileSync(tmpPath, JSON.stringify(store, null, 2), {
266
+ mode: 0o600,
267
+ flush: true,
268
+ });
255
269
  chmodSync(tmpPath, 0o600);
256
270
  renameSync(tmpPath, storePath);
271
+
272
+ // Fsync the parent directory so the rename itself is durable. `{flush:true}`
273
+ // syncs the temp file's contents, not the directory entry the rename updates,
274
+ // so without this a host-level crash could still expose the previous keys.enc.
275
+ // Best-effort: a directory-fsync failure must not fail the write.
276
+ try {
277
+ const dirFd = openSync(protectedDir, "r");
278
+ try {
279
+ fsyncSync(dirFd);
280
+ } finally {
281
+ closeSync(dirFd);
282
+ }
283
+ } catch {
284
+ // Directory fsync is a durability nicety, not required for correctness.
285
+ }
257
286
  }
258
287
 
259
288
  // ---------------------------------------------------------------------------
@@ -330,7 +359,10 @@ export class StoreUnavailableError extends Error {
330
359
  */
331
360
  export function createLocalSecureKeyBackend(
332
361
  vellumRoot: string,
333
- options?: { entropyOverride?: string; entropyGetter?: () => string | undefined },
362
+ options?: {
363
+ entropyOverride?: string;
364
+ entropyGetter?: () => string | undefined;
365
+ },
334
366
  ): SecureKeyBackend {
335
367
  const storePath = join(resolveSecurityDir(vellumRoot), KEYS_ENC_FILENAME);
336
368
  const staticEntropy = options?.entropyOverride;