@vellumai/credential-executor 0.5.6 → 0.5.7

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.
@@ -2,6 +2,7 @@
2
2
  "name": "@vellumai/ces-contracts",
3
3
  "version": "0.0.1",
4
4
  "private": true,
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "exports": {
7
8
  ".": "./src/index.ts",
@@ -25,6 +25,12 @@
25
25
  *
26
26
  * **Lifecycle**
27
27
  * - `update_managed_credential` — Push an updated API key to CES after hatch
28
+ *
29
+ * **Credential CRUD**
30
+ * - `get_credential` — Retrieve a credential by account name
31
+ * - `set_credential` — Store or update a credential
32
+ * - `delete_credential` — Delete a credential by account name
33
+ * - `list_credentials` — List all credential account names
28
34
  */
29
35
 
30
36
  import { z } from "zod/v4";
@@ -51,6 +57,14 @@ export const CesRpcMethod = {
51
57
  ListAuditRecords: "list_audit_records",
52
58
  /** Push an updated assistant credential to CES after post-hatch provisioning. */
53
59
  UpdateManagedCredential: "update_managed_credential",
60
+ /** Retrieve a single credential by account name. */
61
+ GetCredential: "get_credential",
62
+ /** Store or update a credential by account name. */
63
+ SetCredential: "set_credential",
64
+ /** Delete a credential by account name. */
65
+ DeleteCredential: "delete_credential",
66
+ /** List all credential account names. */
67
+ ListCredentials: "list_credentials",
54
68
  } as const;
55
69
 
56
70
  export type CesRpcMethod =
@@ -421,6 +435,79 @@ export type UpdateManagedCredentialResponse = z.infer<
421
435
  typeof UpdateManagedCredentialResponseSchema
422
436
  >;
423
437
 
438
+ // ---------------------------------------------------------------------------
439
+ // get_credential
440
+ // ---------------------------------------------------------------------------
441
+
442
+ export const GetCredentialSchema = z.object({
443
+ /** The account name to look up. */
444
+ account: z.string(),
445
+ });
446
+ export type GetCredential = z.infer<typeof GetCredentialSchema>;
447
+
448
+ export const GetCredentialResponseSchema = z.object({
449
+ /** Whether the credential was found. */
450
+ found: z.boolean(),
451
+ /** The credential value (present only when found). */
452
+ value: z.string().optional(),
453
+ });
454
+ export type GetCredentialResponse = z.infer<
455
+ typeof GetCredentialResponseSchema
456
+ >;
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // set_credential
460
+ // ---------------------------------------------------------------------------
461
+
462
+ export const SetCredentialSchema = z.object({
463
+ /** The account name to store the credential under. */
464
+ account: z.string(),
465
+ /** The credential value to store. */
466
+ value: z.string(),
467
+ });
468
+ export type SetCredential = z.infer<typeof SetCredentialSchema>;
469
+
470
+ export const SetCredentialResponseSchema = z.object({
471
+ /** Whether the credential was successfully stored. */
472
+ ok: z.boolean(),
473
+ });
474
+ export type SetCredentialResponse = z.infer<
475
+ typeof SetCredentialResponseSchema
476
+ >;
477
+
478
+ // ---------------------------------------------------------------------------
479
+ // delete_credential
480
+ // ---------------------------------------------------------------------------
481
+
482
+ export const DeleteCredentialSchema = z.object({
483
+ /** The account name to delete. */
484
+ account: z.string(),
485
+ });
486
+ export type DeleteCredential = z.infer<typeof DeleteCredentialSchema>;
487
+
488
+ export const DeleteCredentialResponseSchema = z.object({
489
+ /** The result of the delete operation. */
490
+ result: z.enum(["deleted", "not-found", "error"]),
491
+ });
492
+ export type DeleteCredentialResponse = z.infer<
493
+ typeof DeleteCredentialResponseSchema
494
+ >;
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // list_credentials
498
+ // ---------------------------------------------------------------------------
499
+
500
+ export const ListCredentialsSchema = z.object({});
501
+ export type ListCredentials = z.infer<typeof ListCredentialsSchema>;
502
+
503
+ export const ListCredentialsResponseSchema = z.object({
504
+ /** The account names of all stored credentials. */
505
+ accounts: z.array(z.string()),
506
+ });
507
+ export type ListCredentialsResponse = z.infer<
508
+ typeof ListCredentialsResponseSchema
509
+ >;
510
+
424
511
  // ---------------------------------------------------------------------------
425
512
  // Full RPC contract type map
426
513
  // ---------------------------------------------------------------------------
@@ -466,6 +553,22 @@ export interface CesRpcContract {
466
553
  request: UpdateManagedCredential;
467
554
  response: UpdateManagedCredentialResponse;
468
555
  };
556
+ [CesRpcMethod.GetCredential]: {
557
+ request: GetCredential;
558
+ response: GetCredentialResponse;
559
+ };
560
+ [CesRpcMethod.SetCredential]: {
561
+ request: SetCredential;
562
+ response: SetCredentialResponse;
563
+ };
564
+ [CesRpcMethod.DeleteCredential]: {
565
+ request: DeleteCredential;
566
+ response: DeleteCredentialResponse;
567
+ };
568
+ [CesRpcMethod.ListCredentials]: {
569
+ request: ListCredentials;
570
+ response: ListCredentialsResponse;
571
+ };
469
572
  }
470
573
 
471
574
  /**
@@ -508,4 +611,20 @@ export const CesRpcSchemas = {
508
611
  request: UpdateManagedCredentialSchema,
509
612
  response: UpdateManagedCredentialResponseSchema,
510
613
  },
614
+ [CesRpcMethod.GetCredential]: {
615
+ request: GetCredentialSchema,
616
+ response: GetCredentialResponseSchema,
617
+ },
618
+ [CesRpcMethod.SetCredential]: {
619
+ request: SetCredentialSchema,
620
+ response: SetCredentialResponseSchema,
621
+ },
622
+ [CesRpcMethod.DeleteCredential]: {
623
+ request: DeleteCredentialSchema,
624
+ response: DeleteCredentialResponseSchema,
625
+ },
626
+ [CesRpcMethod.ListCredentials]: {
627
+ request: ListCredentialsSchema,
628
+ response: ListCredentialsResponseSchema,
629
+ },
511
630
  } as const;
@@ -2,6 +2,7 @@
2
2
  "name": "@vellumai/credential-storage",
3
3
  "version": "0.0.1",
4
4
  "private": true,
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "exports": {
7
8
  ".": "./src/index.ts"
@@ -2,6 +2,7 @@
2
2
  "name": "@vellumai/egress-proxy",
3
3
  "version": "0.0.1",
4
4
  "private": true,
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "exports": {
7
8
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
+ "license": "MIT",
4
5
  "type": "module",
5
6
  "exports": {
6
7
  ".": "./src/index.ts"
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Filesystem-level tests for the CES local SecureKeyBackend.
3
+ *
4
+ * These tests exercise `createLocalSecureKeyBackend` with real files and
5
+ * env-var overrides, separate from the in-memory resolver/materialiser
6
+ * tests in `local-materializers.test.ts`.
7
+ */
8
+
9
+ import { afterEach, describe, expect, test } from "bun:test";
10
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
11
+ import { randomBytes } from "node:crypto";
12
+ import { join } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+
15
+ import { createLocalSecureKeyBackend } from "../materializers/local-secure-key-backend.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function makeTmpDir(): string {
22
+ const dir = join(tmpdir(), `ces-backend-test-${randomBytes(8).toString("hex")}`);
23
+ mkdirSync(dir, { recursive: true });
24
+ return dir;
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Tests
29
+ // ---------------------------------------------------------------------------
30
+
31
+ describe("createLocalSecureKeyBackend — filesystem", () => {
32
+ let tmpDir: string | undefined;
33
+ let savedSecurityDir: string | undefined;
34
+
35
+ afterEach(() => {
36
+ if (savedSecurityDir !== undefined) {
37
+ process.env.CREDENTIAL_SECURITY_DIR = savedSecurityDir;
38
+ } else {
39
+ delete process.env.CREDENTIAL_SECURITY_DIR;
40
+ }
41
+ if (tmpDir && existsSync(tmpDir)) {
42
+ rmSync(tmpDir, { recursive: true, force: true });
43
+ }
44
+ tmpDir = undefined;
45
+ savedSecurityDir = undefined;
46
+ });
47
+
48
+ function setup(): { securityDir: string; vellumRoot: string } {
49
+ tmpDir = makeTmpDir();
50
+ const securityDir = join(tmpDir, "security");
51
+ mkdirSync(securityDir, { recursive: true });
52
+ savedSecurityDir = process.env.CREDENTIAL_SECURITY_DIR;
53
+ process.env.CREDENTIAL_SECURITY_DIR = securityDir;
54
+ // vellumRoot is unused when CREDENTIAL_SECURITY_DIR is set,
55
+ // but we pass tmpDir for consistency
56
+ return { securityDir, vellumRoot: tmpDir };
57
+ }
58
+
59
+ test("set() on a fresh security directory creates store.key and keys.enc", async () => {
60
+ const { securityDir, vellumRoot } = setup();
61
+ const backend = createLocalSecureKeyBackend(vellumRoot);
62
+
63
+ const result = await backend.set("test/key", "secret-value");
64
+ expect(result).toBe(true);
65
+
66
+ // store.key exists, is 32 bytes, mode 0o600
67
+ const keyPath = join(securityDir, "store.key");
68
+ expect(existsSync(keyPath)).toBe(true);
69
+ const keyBuf = readFileSync(keyPath);
70
+ expect(keyBuf.length).toBe(32);
71
+ const mode = statSync(keyPath).mode & 0o777;
72
+ expect(mode).toBe(0o600);
73
+
74
+ // keys.enc exists and is valid v2 JSON
75
+ const encPath = join(securityDir, "keys.enc");
76
+ expect(existsSync(encPath)).toBe(true);
77
+ const store = JSON.parse(readFileSync(encPath, "utf-8"));
78
+ expect(store.version).toBe(2);
79
+ expect(typeof store.entries).toBe("object");
80
+
81
+ // Round-trip
82
+ const value = await backend.get("test/key");
83
+ expect(value).toBe("secret-value");
84
+ });
85
+
86
+ test("subsequent set() calls append to the existing store without recreating files", async () => {
87
+ const { securityDir, vellumRoot } = setup();
88
+ const backend = createLocalSecureKeyBackend(vellumRoot);
89
+
90
+ await backend.set("key1", "val1");
91
+ const keyAfterFirst = readFileSync(join(securityDir, "store.key"));
92
+
93
+ await backend.set("key2", "val2");
94
+ const keyAfterSecond = readFileSync(join(securityDir, "store.key"));
95
+
96
+ // store.key was not regenerated
97
+ expect(Buffer.compare(keyAfterFirst, keyAfterSecond)).toBe(0);
98
+
99
+ // keys.enc has exactly 2 entries
100
+ const store = JSON.parse(readFileSync(join(securityDir, "keys.enc"), "utf-8"));
101
+ expect(Object.keys(store.entries).length).toBe(2);
102
+
103
+ // Both values round-trip
104
+ expect(await backend.get("key1")).toBe("val1");
105
+ expect(await backend.get("key2")).toBe("val2");
106
+ });
107
+
108
+ test("set() against a pre-existing v1 store preserves format and round-trips", async () => {
109
+ const { securityDir, vellumRoot } = setup();
110
+
111
+ // Manually write a v1 store
112
+ const salt = randomBytes(32).toString("hex");
113
+ const v1Store = { version: 1, salt, entries: {} };
114
+ writeFileSync(join(securityDir, "keys.enc"), JSON.stringify(v1Store, null, 2), {
115
+ mode: 0o600,
116
+ });
117
+
118
+ const backend = createLocalSecureKeyBackend(vellumRoot);
119
+ const result = await backend.set("v1-key", "v1-value");
120
+ expect(result).toBe(true);
121
+
122
+ // Read back and verify format preserved
123
+ const storeAfter = JSON.parse(readFileSync(join(securityDir, "keys.enc"), "utf-8"));
124
+ expect(storeAfter.version).toBe(1);
125
+ expect(storeAfter.salt).toBe(salt);
126
+ expect(Object.keys(storeAfter.entries)).toContain("v1-key");
127
+
128
+ // store.key was NOT created (v1 stores don't use it)
129
+ expect(existsSync(join(securityDir, "store.key"))).toBe(false);
130
+
131
+ // Round-trip
132
+ const value = await backend.get("v1-key");
133
+ expect(value).toBe("v1-value");
134
+ });
135
+
136
+ test("get() returns undefined when no store exists", async () => {
137
+ const { vellumRoot } = setup();
138
+ const backend = createLocalSecureKeyBackend(vellumRoot);
139
+ const value = await backend.get("anything");
140
+ expect(value).toBeUndefined();
141
+ });
142
+
143
+ test("list() returns empty array when no store exists", async () => {
144
+ const { vellumRoot } = setup();
145
+ const backend = createLocalSecureKeyBackend(vellumRoot);
146
+ const keys = await backend.list();
147
+ expect(keys).toEqual([]);
148
+ });
149
+
150
+ test("delete() returns error when no store exists", async () => {
151
+ const { vellumRoot } = setup();
152
+ const backend = createLocalSecureKeyBackend(vellumRoot);
153
+ const result = await backend.delete("anything");
154
+ expect(result).toBe("error");
155
+ });
156
+ });
@@ -351,13 +351,13 @@ describe("managed CES integration (real Unix socket)", () => {
351
351
  const url = new URL(req.url);
352
352
  if (url.pathname === "/healthz") {
353
353
  return new Response(
354
- JSON.stringify({ status: "ok", version: CES_PROTOCOL_VERSION }),
354
+ JSON.stringify({ status: "ok" }),
355
355
  { headers: { "Content-Type": "application/json" } },
356
356
  );
357
357
  }
358
358
  if (url.pathname === "/readyz") {
359
359
  return new Response(
360
- JSON.stringify({ ready: true, version: CES_PROTOCOL_VERSION }),
360
+ JSON.stringify({ status: "ok", rpcConnected: false }),
361
361
  { status: 200, headers: { "Content-Type": "application/json" } },
362
362
  );
363
363
  }
@@ -466,13 +466,11 @@ describe("managed CES integration (real Unix socket)", () => {
466
466
  expect(healthzResp.status).toBe(200);
467
467
  const healthzBody = await healthzResp.json();
468
468
  expect(healthzBody.status).toBe("ok");
469
- expect(healthzBody.version).toBe(CES_PROTOCOL_VERSION);
470
469
 
471
470
  const readyzResp = await fetch(`http://localhost:${healthPort}/readyz`);
472
471
  expect(readyzResp.status).toBe(200);
473
472
  const readyzBody = await readyzResp.json();
474
- expect(readyzBody.ready).toBe(true);
475
- expect(readyzBody.version).toBe(CES_PROTOCOL_VERSION);
473
+ expect(readyzBody.status).toBe("ok");
476
474
 
477
475
  // -- Step 5: Unknown method returns METHOD_NOT_FOUND -----------------------
478
476
  const unknownRpcId = "rpc-3";
package/src/main.ts CHANGED
@@ -261,6 +261,27 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
261
261
  auditStore,
262
262
  }) as typeof handlers[string];
263
263
 
264
+ // Register credential CRUD handlers
265
+ handlers[CesRpcMethod.GetCredential] = (async (req: { account: string }) => {
266
+ const value = await secureKeyBackend.get(req.account);
267
+ return { found: value !== undefined, value };
268
+ }) as typeof handlers[string];
269
+
270
+ handlers[CesRpcMethod.SetCredential] = (async (req: { account: string; value: string }) => {
271
+ const ok = await secureKeyBackend.set(req.account, req.value);
272
+ return { ok };
273
+ }) as typeof handlers[string];
274
+
275
+ handlers[CesRpcMethod.DeleteCredential] = (async (req: { account: string }) => {
276
+ const result = await secureKeyBackend.delete(req.account);
277
+ return { result };
278
+ }) as typeof handlers[string];
279
+
280
+ handlers[CesRpcMethod.ListCredentials] = (async () => {
281
+ const accounts = await secureKeyBackend.list();
282
+ return { accounts };
283
+ }) as typeof handlers[string];
284
+
264
285
  return handlers;
265
286
  }
266
287
 
@@ -110,7 +110,7 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
110
110
  // from the bootstrap handshake (the assistant forwards it after hatch).
111
111
  // We use a lazy getter so the handshake-provided key takes effect even
112
112
  // though handlers are built before the handshake completes.
113
- const platformBaseUrl = process.env["PLATFORM_BASE_URL"] ?? "";
113
+ const platformBaseUrl = process.env["VELLUM_PLATFORM_URL"] ?? "";
114
114
  const assistantId = process.env["PLATFORM_ASSISTANT_ID"] ?? "";
115
115
 
116
116
  const { getAssistantApiKey, getManagedSubjectOptions, getManagedMaterializerOptions } =
@@ -123,7 +123,7 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
123
123
 
124
124
  if (!platformBaseUrl || !assistantId) {
125
125
  warn(
126
- "PLATFORM_BASE_URL and/or PLATFORM_ASSISTANT_ID not set. " +
126
+ "VELLUM_PLATFORM_URL and/or PLATFORM_ASSISTANT_ID not set. " +
127
127
  "Managed credential materialisation will depend on the handshake-provided API key.",
128
128
  );
129
129
  }
@@ -207,7 +207,7 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
207
207
  return {
208
208
  ok: false as const,
209
209
  error:
210
- "PLATFORM_BASE_URL and/or ASSISTANT_API_KEY not set. " +
210
+ "VELLUM_PLATFORM_URL and/or ASSISTANT_API_KEY not set. " +
211
211
  "Managed credential materialisation is not available.",
212
212
  };
213
213
  }
@@ -342,7 +342,7 @@ function startHealthServer(
342
342
  const url = new URL(req.url);
343
343
  if (url.pathname === "/healthz") {
344
344
  return new Response(
345
- JSON.stringify({ status: "ok", version: CES_PROTOCOL_VERSION }),
345
+ JSON.stringify({ status: "ok" }),
346
346
  { headers: { "Content-Type": "application/json" } },
347
347
  );
348
348
  }
@@ -354,7 +354,7 @@ function startHealthServer(
354
354
  // without a connection anyway, so readiness is purely about the
355
355
  // process being up and able to accept a future connection.
356
356
  return new Response(
357
- JSON.stringify({ ready: true, rpcConnected, version: CES_PROTOCOL_VERSION }),
357
+ JSON.stringify({ status: "ok", rpcConnected }),
358
358
  {
359
359
  status: 200,
360
360
  headers: { "Content-Type": "application/json" },
@@ -123,6 +123,61 @@ function readStoreKey(vellumRoot: string): Buffer | null {
123
123
  }
124
124
  }
125
125
 
126
+ // ---------------------------------------------------------------------------
127
+ // Auto-initialization helpers
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Read or generate the v2 store key. If the key file does not exist,
132
+ * creates a new 32-byte random key and writes it atomically.
133
+ */
134
+ function getOrReadStoreKey(vellumRoot: string): Buffer {
135
+ const existing = readStoreKey(vellumRoot);
136
+ if (existing) return existing;
137
+
138
+ const securityDir = resolveSecurityDir(vellumRoot);
139
+ mkdirSync(securityDir, { recursive: true });
140
+
141
+ const key = randomBytes(KEY_LENGTH);
142
+ const keyPath = join(securityDir, STORE_KEY_FILENAME);
143
+ const tmpPath = keyPath + `.tmp.${process.pid}`;
144
+ writeFileSync(tmpPath, key, { mode: 0o600 });
145
+ chmodSync(tmpPath, 0o600);
146
+ renameSync(tmpPath, keyPath);
147
+
148
+ return key;
149
+ }
150
+
151
+ /**
152
+ * Read an existing store or create a new empty v2 store. Returns the store
153
+ * and the AES key needed to encrypt/decrypt entries.
154
+ *
155
+ * For existing v1 stores, throws so the caller can fall back to the legacy
156
+ * PBKDF2 path (v1 stores cannot be auto-initialized with a store key).
157
+ */
158
+ function getOrCreateStore(
159
+ storePath: string,
160
+ vellumRoot: string,
161
+ ): { store: StoreFile; aesKey: Buffer } {
162
+ const existing = readStore(storePath);
163
+ if (existing) {
164
+ if (existing.version === 1) {
165
+ throw new Error("v1 store cannot be auto-initialized");
166
+ }
167
+ const storeKey = readStoreKey(vellumRoot);
168
+ if (!storeKey) {
169
+ throw new Error("v2 store exists but store.key is missing or corrupt");
170
+ }
171
+ return { store: existing, aesKey: storeKey };
172
+ }
173
+
174
+ // No store exists — create a new empty v2 store
175
+ const aesKey = getOrReadStoreKey(vellumRoot);
176
+ const store: StoreFileV2 = { version: 2, entries: {} };
177
+ writeStore(store, storePath);
178
+ return { store, aesKey };
179
+ }
180
+
126
181
  // ---------------------------------------------------------------------------
127
182
  // Machine entropy (must match assistant/src/security/encrypted-store.ts)
128
183
  // ---------------------------------------------------------------------------
@@ -287,19 +342,25 @@ export function createLocalSecureKeyBackend(
287
342
  // future improvement.
288
343
  async set(key: string, value: string): Promise<boolean> {
289
344
  try {
290
- const store = readStore(storePath);
291
- if (!store) return false;
292
-
345
+ let store: StoreFile;
293
346
  let aesKey: Buffer;
294
- if (store.version === 2) {
295
- const storeKey = readStoreKey(vellumRoot);
296
- if (!storeKey) return false;
297
- aesKey = storeKey;
298
- } else {
299
- // v1: derive key from machine entropy via PBKDF2
300
- const entropy = entropyGetter?.() ?? staticEntropy;
301
- const salt = Buffer.from(store.salt, "hex");
302
- aesKey = deriveKey(salt, entropy);
347
+
348
+ try {
349
+ const result = getOrCreateStore(storePath, vellumRoot);
350
+ store = result.store;
351
+ aesKey = result.aesKey;
352
+ } catch {
353
+ // Fallback: v1 store or other error — try legacy PBKDF2 path
354
+ const existing = readStore(storePath);
355
+ if (!existing) return false;
356
+ store = existing;
357
+ if (store.version === 1) {
358
+ const entropy = entropyGetter?.() ?? staticEntropy;
359
+ const salt = Buffer.from(store.salt, "hex");
360
+ aesKey = deriveKey(salt, entropy);
361
+ } else {
362
+ return false;
363
+ }
303
364
  }
304
365
 
305
366
  store.entries[key] = encrypt(value, aesKey);