@vellumai/credential-executor 0.8.12-staging.2 → 0.9.0-staging.1

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.8.12-staging.2",
3
+ "version": "0.9.0-staging.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,7 +12,10 @@ import { randomBytes } from "node:crypto";
12
12
  import { join } from "node:path";
13
13
  import { tmpdir } from "node:os";
14
14
 
15
- import { createLocalSecureKeyBackend } from "../materializers/local-secure-key-backend.js";
15
+ import {
16
+ createLocalSecureKeyBackend,
17
+ StoreUnavailableError,
18
+ } from "../materializers/local-secure-key-backend.js";
16
19
 
17
20
  // ---------------------------------------------------------------------------
18
21
  // Helpers
@@ -153,4 +156,49 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
153
156
  const result = await backend.delete("anything");
154
157
  expect(result).toBe("error");
155
158
  });
159
+
160
+ // -------------------------------------------------------------------------
161
+ // UNAVAILABLE (store exists but cannot be read / decrypted) must be distinct
162
+ // from ABSENT (no store, or the store reads cleanly but lacks the key). The
163
+ // former throws so the RPC layer reports `unreachable`; the latter returns
164
+ // undefined/[]. This is the cold-start fix: a transiently-unreadable store or
165
+ // missing key material must never masquerade as "credential not found".
166
+ // -------------------------------------------------------------------------
167
+
168
+ test("get() THROWS (not undefined) when the store file exists but is unreadable", async () => {
169
+ const { securityDir, vellumRoot } = setup();
170
+ // A store file that exists but cannot be parsed (e.g. a partial/corrupt
171
+ // read) — distinct from no store at all.
172
+ writeFileSync(join(securityDir, "keys.enc"), "{ not valid json", {
173
+ mode: 0o600,
174
+ });
175
+ const backend = createLocalSecureKeyBackend(vellumRoot);
176
+ await expect(backend.get("anything")).rejects.toThrow(StoreUnavailableError);
177
+ });
178
+
179
+ test("get() returns undefined when the store reads cleanly but the key is absent", async () => {
180
+ const { vellumRoot } = setup();
181
+ const backend = createLocalSecureKeyBackend(vellumRoot);
182
+ await backend.set("present/key", "v"); // valid store with one entry
183
+ // A genuinely missing key in a readable store is ABSENT, not unavailable.
184
+ expect(await backend.get("absent/key")).toBeUndefined();
185
+ });
186
+
187
+ test("get() THROWS when a v2 entry exists but store.key is missing (cold-start key-material race)", async () => {
188
+ const { securityDir, vellumRoot } = setup();
189
+ const backend = createLocalSecureKeyBackend(vellumRoot);
190
+ await backend.set("k", "v"); // creates the v2 store + store.key
191
+ expect(await backend.get("k")).toBe("v"); // sanity: warm read works
192
+ // Simulate the cold-start window where the key material is transiently
193
+ // unavailable: the entry still exists, but it cannot be decrypted yet.
194
+ rmSync(join(securityDir, "store.key"));
195
+ await expect(backend.get("k")).rejects.toThrow(StoreUnavailableError);
196
+ });
197
+
198
+ test("list() THROWS (not []) when the store file exists but is unreadable", async () => {
199
+ const { securityDir, vellumRoot } = setup();
200
+ writeFileSync(join(securityDir, "keys.enc"), "{ corrupt", { mode: 0o600 });
201
+ const backend = createLocalSecureKeyBackend(vellumRoot);
202
+ await expect(backend.list()).rejects.toThrow(StoreUnavailableError);
203
+ });
156
204
  });
@@ -65,7 +65,7 @@ function rowToRecord(row: OAuthConnectionRow): OAuthConnectionRecord {
65
65
  * Create a read-only OAuth connection lookup backed by the assistant's
66
66
  * SQLite database.
67
67
  *
68
- * @param workspaceDir - The workspace directory (e.g. `~/.vellum/workspace`).
68
+ * @param workspaceDir - The workspace directory (e.g. `$VELLUM_WORKSPACE_DIR`).
69
69
  */
70
70
  export function createLocalOAuthLookup(
71
71
  workspaceDir: string,
@@ -278,6 +278,34 @@ function readStore(storePath: string): StoreFile | null {
278
278
  }
279
279
  }
280
280
 
281
+ // ---------------------------------------------------------------------------
282
+ // Errors
283
+ // ---------------------------------------------------------------------------
284
+
285
+ /**
286
+ * Thrown by `get()` / `list()` when the credential store is UNAVAILABLE — the
287
+ * store file exists but cannot be read, the v2 `store.key` (or v1 machine
288
+ * entropy) key material is missing, or an entry fails to decrypt. This is
289
+ * deliberately DISTINCT from a credential being ABSENT (no store file yet, or
290
+ * the store reads cleanly but lacks the requested key), which returns
291
+ * `undefined` / `[]`.
292
+ *
293
+ * The distinction matters on CES cold start: for a brief window after a
294
+ * (re)start the key material can be transiently unreadable (see the
295
+ * `entropyGetter` note on {@link createLocalSecureKeyBackend} — "the file may
296
+ * not exist at construction time but appears later"). Returning `undefined`
297
+ * there reports a credential that EXISTS as "not found"; throwing instead
298
+ * surfaces the true state to the RPC layer (a `HANDLER_ERROR` the daemon maps
299
+ * to `unreachable`), so a transient read failure is never mistaken for a
300
+ * missing credential.
301
+ */
302
+ export class StoreUnavailableError extends Error {
303
+ constructor(message: string) {
304
+ super(message);
305
+ this.name = "StoreUnavailableError";
306
+ }
307
+ }
308
+
281
309
  // ---------------------------------------------------------------------------
282
310
  // Backend implementation
283
311
  // ---------------------------------------------------------------------------
@@ -310,29 +338,50 @@ export function createLocalSecureKeyBackend(
310
338
 
311
339
  return {
312
340
  async get(key: string): Promise<string | undefined> {
313
- try {
314
- const store = readStore(storePath);
315
- if (!store) return undefined;
316
-
317
- const entry = store.entries[key];
318
- if (!entry) return undefined;
319
-
320
- let aesKey: Buffer;
321
- if (store.version === 2) {
322
- const storeKey = readStoreKey(vellumRoot);
323
- if (!storeKey) return undefined;
324
- aesKey = storeKey;
325
- } else {
326
- // v1: derive key from machine entropy via PBKDF2
327
- const entropy = entropyGetter?.() ?? staticEntropy;
328
- const salt = Buffer.from(store.salt, "hex");
329
- aesKey = deriveKey(salt, entropy);
341
+ const store = readStore(storePath);
342
+ if (!store) {
343
+ // No store file at all → the credential is genuinely ABSENT. A store
344
+ // file that EXISTS but could not be read is UNAVAILABLE, not absent —
345
+ // surface it rather than reporting a false "not found".
346
+ if (existsSync(storePath)) {
347
+ throw new StoreUnavailableError(
348
+ "credential store exists but could not be read",
349
+ );
330
350
  }
331
-
332
- return decrypt(entry, aesKey);
333
- } catch {
334
351
  return undefined;
335
352
  }
353
+
354
+ const entry = store.entries[key];
355
+ // The store read cleanly and has no such entry → genuinely not found.
356
+ if (!entry) return undefined;
357
+
358
+ let aesKey: Buffer;
359
+ if (store.version === 2) {
360
+ const storeKey = readStoreKey(vellumRoot);
361
+ if (!storeKey) {
362
+ // The entry exists but the key material to decrypt it is unavailable
363
+ // (the cold-start race). Unavailable, NOT not-found.
364
+ throw new StoreUnavailableError(
365
+ "v2 store entry present but store.key is unavailable",
366
+ );
367
+ }
368
+ aesKey = storeKey;
369
+ } else {
370
+ // v1: derive key from machine entropy via PBKDF2. `entropy` is an
371
+ // OPTIONAL override (managed mode); when undefined, deriveKey falls
372
+ // back to local machine entropy — the normal local-mode path, so an
373
+ // undefined value here is NOT "unavailable". A v1 cold-start race (the
374
+ // override entropy file not yet present) yields the wrong key and
375
+ // surfaces below as a decrypt failure → unavailable.
376
+ const entropy = entropyGetter?.() ?? staticEntropy;
377
+ const salt = Buffer.from(store.salt, "hex");
378
+ aesKey = deriveKey(salt, entropy);
379
+ }
380
+
381
+ // A decrypt failure means corrupt data or wrong key material — the
382
+ // credential exists but we cannot produce its value. Let it propagate as
383
+ // unavailable rather than swallowing it into a false "not found".
384
+ return decrypt(entry, aesKey);
336
385
  },
337
386
 
338
387
  // NOTE: read-modify-write without file locking. The atomic rename
@@ -387,13 +436,18 @@ export function createLocalSecureKeyBackend(
387
436
  },
388
437
 
389
438
  async list(): Promise<string[]> {
390
- try {
391
- const store = readStore(storePath);
392
- if (!store) return [];
393
- return Object.keys(store.entries);
394
- } catch {
439
+ const store = readStore(storePath);
440
+ if (!store) {
441
+ // Mirror get(): a store file that exists but is unreadable is
442
+ // UNAVAILABLE (surface it), not an empty credential set.
443
+ if (existsSync(storePath)) {
444
+ throw new StoreUnavailableError(
445
+ "credential store exists but could not be read",
446
+ );
447
+ }
395
448
  return [];
396
449
  }
450
+ return Object.keys(store.entries);
397
451
  },
398
452
  };
399
453
  }