@vellumai/credential-executor 0.5.6 → 0.5.8

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",
@@ -3,7 +3,7 @@
3
3
  * dependencies between index.ts and rpc.ts.
4
4
  */
5
5
 
6
- import { z } from "zod/v4";
6
+ import { z } from "zod";
7
7
 
8
8
  export const RpcErrorSchema = z.object({
9
9
  code: z.string(),
@@ -9,7 +9,7 @@
9
9
  * - Audit record summaries (materialization events)
10
10
  */
11
11
 
12
- import { z } from "zod/v4";
12
+ import { z } from "zod";
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Grant proposal types
@@ -20,7 +20,7 @@
20
20
  * is the platform-assigned connection identifier.
21
21
  */
22
22
 
23
- import { z } from "zod/v4";
23
+ import { z } from "zod";
24
24
 
25
25
  // ---------------------------------------------------------------------------
26
26
  // Handle type discriminator
@@ -7,7 +7,7 @@
7
7
  * module so that both sides can depend on it without circular references.
8
8
  */
9
9
 
10
- import { z } from "zod/v4";
10
+ import { z } from "zod";
11
11
  import { RpcErrorSchema } from "./error.js";
12
12
 
13
13
  // ---------------------------------------------------------------------------
@@ -25,9 +25,15 @@
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
- import { z } from "zod/v4";
36
+ import { z } from "zod";
31
37
  import {
32
38
  AuditRecordSummarySchema,
33
39
  GrantProposalSchema,
@@ -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.8",
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
+ });
@@ -29,6 +29,10 @@ import {
29
29
  CesRpcMethod,
30
30
  type HandshakeAck,
31
31
  type ListGrantsResponse,
32
+ type GetCredentialResponse,
33
+ type SetCredentialResponse,
34
+ type DeleteCredentialResponse,
35
+ type ListCredentialsResponse,
32
36
  type RpcEnvelope,
33
37
  } from "@vellumai/ces-contracts";
34
38
 
@@ -40,6 +44,7 @@ import {
40
44
  createListAuditRecordsHandler,
41
45
  } from "../grants/rpc-handlers.js";
42
46
  import { CesRpcServer, type RpcHandlerRegistry, type SessionIdRef } from "../server.js";
47
+ import { createLocalSecureKeyBackend } from "../materializers/local-secure-key-backend.js";
43
48
 
44
49
  // ---------------------------------------------------------------------------
45
50
  // Environment setup
@@ -52,6 +57,7 @@ const SAVED_ENV_KEYS = [
52
57
  "CES_BOOTSTRAP_SOCKET",
53
58
  "CES_HEALTH_PORT",
54
59
  "CES_MODE",
60
+ "CREDENTIAL_SECURITY_DIR",
55
61
  ] as const;
56
62
 
57
63
  type SavedEnv = Record<string, string | undefined>;
@@ -107,6 +113,39 @@ function buildMinimalHandlers(dataDir: string): RpcHandlerRegistry {
107
113
  return handlers;
108
114
  }
109
115
 
116
+ /**
117
+ * Build an RPC handler registry with credential CRUD handlers backed by
118
+ * a real SecureKeyBackend using a temp directory for credential storage.
119
+ *
120
+ * Mirrors the handler registration in managed-main.ts.
121
+ */
122
+ function buildCredentialHandlers(vellumRoot: string): RpcHandlerRegistry {
123
+ const secureKeyBackend = createLocalSecureKeyBackend(vellumRoot);
124
+ const handlers: RpcHandlerRegistry = {};
125
+
126
+ handlers[CesRpcMethod.GetCredential] = (async (req: { account: string }) => {
127
+ const value = await secureKeyBackend.get(req.account);
128
+ return { found: value !== undefined, value };
129
+ }) as typeof handlers[string];
130
+
131
+ handlers[CesRpcMethod.SetCredential] = (async (req: { account: string; value: string }) => {
132
+ const ok = await secureKeyBackend.set(req.account, req.value);
133
+ return { ok };
134
+ }) as typeof handlers[string];
135
+
136
+ handlers[CesRpcMethod.DeleteCredential] = (async (req: { account: string }) => {
137
+ const result = await secureKeyBackend.delete(req.account);
138
+ return { result };
139
+ }) as typeof handlers[string];
140
+
141
+ handlers[CesRpcMethod.ListCredentials] = (async () => {
142
+ const accounts = await secureKeyBackend.list();
143
+ return { accounts };
144
+ }) as typeof handlers[string];
145
+
146
+ return handlers;
147
+ }
148
+
110
149
  /**
111
150
  * Accept a single connection on a Unix socket and return
112
151
  * readable/writable streams plus cleanup helpers.
@@ -351,13 +390,13 @@ describe("managed CES integration (real Unix socket)", () => {
351
390
  const url = new URL(req.url);
352
391
  if (url.pathname === "/healthz") {
353
392
  return new Response(
354
- JSON.stringify({ status: "ok", version: CES_PROTOCOL_VERSION }),
393
+ JSON.stringify({ status: "ok" }),
355
394
  { headers: { "Content-Type": "application/json" } },
356
395
  );
357
396
  }
358
397
  if (url.pathname === "/readyz") {
359
398
  return new Response(
360
- JSON.stringify({ ready: true, version: CES_PROTOCOL_VERSION }),
399
+ JSON.stringify({ status: "ok", rpcConnected: false }),
361
400
  { status: 200, headers: { "Content-Type": "application/json" } },
362
401
  );
363
402
  }
@@ -466,13 +505,11 @@ describe("managed CES integration (real Unix socket)", () => {
466
505
  expect(healthzResp.status).toBe(200);
467
506
  const healthzBody = await healthzResp.json();
468
507
  expect(healthzBody.status).toBe("ok");
469
- expect(healthzBody.version).toBe(CES_PROTOCOL_VERSION);
470
508
 
471
509
  const readyzResp = await fetch(`http://localhost:${healthPort}/readyz`);
472
510
  expect(readyzResp.status).toBe(200);
473
511
  const readyzBody = await readyzResp.json();
474
- expect(readyzBody.ready).toBe(true);
475
- expect(readyzBody.version).toBe(CES_PROTOCOL_VERSION);
512
+ expect(readyzBody.status).toBe("ok");
476
513
 
477
514
  // -- Step 5: Unknown method returns METHOD_NOT_FOUND -----------------------
478
515
  const unknownRpcId = "rpc-3";
@@ -631,3 +668,277 @@ describe("managed CES integration (real Unix socket)", () => {
631
668
  controller.abort();
632
669
  });
633
670
  });
671
+
672
+ // ---------------------------------------------------------------------------
673
+ // Credential CRUD RPC tests
674
+ // ---------------------------------------------------------------------------
675
+
676
+ describe("credential CRUD RPC", () => {
677
+ /**
678
+ * Helper: set up a Unix socket server with credential CRUD handlers,
679
+ * connect a client, and complete the handshake. Returns the client
680
+ * socket and a serve promise for cleanup.
681
+ */
682
+ async function setupCredentialRpc(): Promise<{
683
+ clientSock: Socket;
684
+ servePromise: Promise<void>;
685
+ }> {
686
+ savedEnv = saveEnv();
687
+ tmpDir = mkdtempSync(join(tmpdir(), "ces-cred-integ-"));
688
+ const dataDir = join(tmpDir, "ces-data");
689
+ const securityDir = join(tmpDir, "security");
690
+ const socketDir = join(tmpDir, "bootstrap");
691
+ const socketPath = join(socketDir, "ces.sock");
692
+ mkdirSync(dataDir, { recursive: true });
693
+ mkdirSync(securityDir, { recursive: true });
694
+ mkdirSync(socketDir, { recursive: true });
695
+
696
+ process.env["CES_DATA_DIR"] = dataDir;
697
+ process.env["CES_MODE"] = "managed";
698
+ process.env["CREDENTIAL_SECURITY_DIR"] = securityDir;
699
+
700
+ controller = new AbortController();
701
+
702
+ const connectionPromise = acceptOneConnection(socketPath, controller.signal);
703
+ clientSocket = await connectToSocket(socketPath);
704
+ const conn = await connectionPromise;
705
+
706
+ // vellumRoot is unused when CREDENTIAL_SECURITY_DIR is set,
707
+ // but we pass dataDir for consistency with the backend API.
708
+ const handlers = buildCredentialHandlers(dataDir);
709
+
710
+ serverRpcServer = new CesRpcServer({
711
+ input: conn.readable,
712
+ output: conn.writable,
713
+ handlers,
714
+ logger: { log: () => {}, warn: () => {}, error: () => {} },
715
+ signal: controller.signal,
716
+ });
717
+
718
+ const servePromise = serverRpcServer.serve();
719
+
720
+ // Complete the handshake
721
+ sendMessage(clientSocket, {
722
+ type: "handshake_request",
723
+ protocolVersion: CES_PROTOCOL_VERSION,
724
+ sessionId: `cred-integ-${Date.now()}`,
725
+ });
726
+ const hsMessages = await readMessages(clientSocket, 1);
727
+ const ack = hsMessages[0] as HandshakeAck;
728
+ expect(ack.type).toBe("handshake_ack");
729
+ expect(ack.accepted).toBe(true);
730
+
731
+ return { clientSock: clientSocket, servePromise };
732
+ }
733
+
734
+ test("set + get round-trip", async () => {
735
+ const { clientSock, servePromise } = await setupCredentialRpc();
736
+
737
+ // Set credential
738
+ sendMessage(clientSock, {
739
+ type: "rpc",
740
+ id: "cred-set-1",
741
+ kind: "request",
742
+ method: CesRpcMethod.SetCredential,
743
+ payload: { account: "test-key", value: "secret-value" },
744
+ timestamp: new Date().toISOString(),
745
+ });
746
+
747
+ const setMessages = await readMessages(clientSock, 1);
748
+ expect(setMessages.length).toBe(1);
749
+ const setResp = setMessages[0] as RpcEnvelope & { type: "rpc" };
750
+ expect(setResp.id).toBe("cred-set-1");
751
+ expect(setResp.kind).toBe("response");
752
+ expect(setResp.method).toBe(CesRpcMethod.SetCredential);
753
+ const setPayload = setResp.payload as SetCredentialResponse;
754
+ expect(setPayload.ok).toBe(true);
755
+
756
+ // Get credential
757
+ sendMessage(clientSock, {
758
+ type: "rpc",
759
+ id: "cred-get-1",
760
+ kind: "request",
761
+ method: CesRpcMethod.GetCredential,
762
+ payload: { account: "test-key" },
763
+ timestamp: new Date().toISOString(),
764
+ });
765
+
766
+ const getMessages = await readMessages(clientSock, 1);
767
+ expect(getMessages.length).toBe(1);
768
+ const getResp = getMessages[0] as RpcEnvelope & { type: "rpc" };
769
+ expect(getResp.id).toBe("cred-get-1");
770
+ expect(getResp.kind).toBe("response");
771
+ expect(getResp.method).toBe(CesRpcMethod.GetCredential);
772
+ const getPayload = getResp.payload as GetCredentialResponse;
773
+ expect(getPayload).toEqual({ found: true, value: "secret-value" });
774
+
775
+ clientSock.end();
776
+ controller.abort();
777
+ await servePromise;
778
+ });
779
+
780
+ test("list includes set key", async () => {
781
+ const { clientSock, servePromise } = await setupCredentialRpc();
782
+
783
+ // Set a credential first
784
+ sendMessage(clientSock, {
785
+ type: "rpc",
786
+ id: "cred-set-2",
787
+ kind: "request",
788
+ method: CesRpcMethod.SetCredential,
789
+ payload: { account: "test-key", value: "secret-value" },
790
+ timestamp: new Date().toISOString(),
791
+ });
792
+ await readMessages(clientSock, 1);
793
+
794
+ // List credentials
795
+ sendMessage(clientSock, {
796
+ type: "rpc",
797
+ id: "cred-list-1",
798
+ kind: "request",
799
+ method: CesRpcMethod.ListCredentials,
800
+ payload: {},
801
+ timestamp: new Date().toISOString(),
802
+ });
803
+
804
+ const listMessages = await readMessages(clientSock, 1);
805
+ expect(listMessages.length).toBe(1);
806
+ const listResp = listMessages[0] as RpcEnvelope & { type: "rpc" };
807
+ expect(listResp.id).toBe("cred-list-1");
808
+ expect(listResp.kind).toBe("response");
809
+ expect(listResp.method).toBe(CesRpcMethod.ListCredentials);
810
+ const listPayload = listResp.payload as ListCredentialsResponse;
811
+ expect(listPayload.accounts).toContain("test-key");
812
+
813
+ clientSock.end();
814
+ controller.abort();
815
+ await servePromise;
816
+ });
817
+
818
+ test("delete credential", async () => {
819
+ const { clientSock, servePromise } = await setupCredentialRpc();
820
+
821
+ // Set a credential first
822
+ sendMessage(clientSock, {
823
+ type: "rpc",
824
+ id: "cred-set-3",
825
+ kind: "request",
826
+ method: CesRpcMethod.SetCredential,
827
+ payload: { account: "test-key", value: "secret-value" },
828
+ timestamp: new Date().toISOString(),
829
+ });
830
+ await readMessages(clientSock, 1);
831
+
832
+ // Delete credential
833
+ sendMessage(clientSock, {
834
+ type: "rpc",
835
+ id: "cred-del-1",
836
+ kind: "request",
837
+ method: CesRpcMethod.DeleteCredential,
838
+ payload: { account: "test-key" },
839
+ timestamp: new Date().toISOString(),
840
+ });
841
+
842
+ const delMessages = await readMessages(clientSock, 1);
843
+ expect(delMessages.length).toBe(1);
844
+ const delResp = delMessages[0] as RpcEnvelope & { type: "rpc" };
845
+ expect(delResp.id).toBe("cred-del-1");
846
+ expect(delResp.kind).toBe("response");
847
+ expect(delResp.method).toBe(CesRpcMethod.DeleteCredential);
848
+ const delPayload = delResp.payload as DeleteCredentialResponse;
849
+ expect(delPayload).toEqual({ result: "deleted" });
850
+
851
+ clientSock.end();
852
+ controller.abort();
853
+ await servePromise;
854
+ });
855
+
856
+ test("get after delete returns not found", async () => {
857
+ const { clientSock, servePromise } = await setupCredentialRpc();
858
+
859
+ // Set a credential
860
+ sendMessage(clientSock, {
861
+ type: "rpc",
862
+ id: "cred-set-4",
863
+ kind: "request",
864
+ method: CesRpcMethod.SetCredential,
865
+ payload: { account: "test-key", value: "secret-value" },
866
+ timestamp: new Date().toISOString(),
867
+ });
868
+ await readMessages(clientSock, 1);
869
+
870
+ // Delete it
871
+ sendMessage(clientSock, {
872
+ type: "rpc",
873
+ id: "cred-del-2",
874
+ kind: "request",
875
+ method: CesRpcMethod.DeleteCredential,
876
+ payload: { account: "test-key" },
877
+ timestamp: new Date().toISOString(),
878
+ });
879
+ await readMessages(clientSock, 1);
880
+
881
+ // Get after delete
882
+ sendMessage(clientSock, {
883
+ type: "rpc",
884
+ id: "cred-get-2",
885
+ kind: "request",
886
+ method: CesRpcMethod.GetCredential,
887
+ payload: { account: "test-key" },
888
+ timestamp: new Date().toISOString(),
889
+ });
890
+
891
+ const getMessages = await readMessages(clientSock, 1);
892
+ expect(getMessages.length).toBe(1);
893
+ const getResp = getMessages[0] as RpcEnvelope & { type: "rpc" };
894
+ expect(getResp.id).toBe("cred-get-2");
895
+ expect(getResp.kind).toBe("response");
896
+ expect(getResp.method).toBe(CesRpcMethod.GetCredential);
897
+ const getPayload = getResp.payload as GetCredentialResponse;
898
+ expect(getPayload).toEqual({ found: false });
899
+
900
+ clientSock.end();
901
+ controller.abort();
902
+ await servePromise;
903
+ });
904
+
905
+ test("delete non-existent credential returns not-found", async () => {
906
+ const { clientSock, servePromise } = await setupCredentialRpc();
907
+
908
+ // Set a credential first to initialize the store file on disk.
909
+ // Without a store file, delete returns "error" (no store) rather
910
+ // than "not-found" (store exists, key absent).
911
+ sendMessage(clientSock, {
912
+ type: "rpc",
913
+ id: "cred-set-init",
914
+ kind: "request",
915
+ method: CesRpcMethod.SetCredential,
916
+ payload: { account: "init-key", value: "init-value" },
917
+ timestamp: new Date().toISOString(),
918
+ });
919
+ await readMessages(clientSock, 1);
920
+
921
+ // Delete a credential that was never set
922
+ sendMessage(clientSock, {
923
+ type: "rpc",
924
+ id: "cred-del-3",
925
+ kind: "request",
926
+ method: CesRpcMethod.DeleteCredential,
927
+ payload: { account: "nonexistent" },
928
+ timestamp: new Date().toISOString(),
929
+ });
930
+
931
+ const delMessages = await readMessages(clientSock, 1);
932
+ expect(delMessages.length).toBe(1);
933
+ const delResp = delMessages[0] as RpcEnvelope & { type: "rpc" };
934
+ expect(delResp.id).toBe("cred-del-3");
935
+ expect(delResp.kind).toBe("response");
936
+ expect(delResp.method).toBe(CesRpcMethod.DeleteCredential);
937
+ const delPayload = delResp.payload as DeleteCredentialResponse;
938
+ expect(delPayload).toEqual({ result: "not-found" });
939
+
940
+ clientSock.end();
941
+ controller.abort();
942
+ await servePromise;
943
+ });
944
+ });
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
 
@@ -57,6 +57,7 @@ import { materializeManagedToken } from "./materializers/managed-platform.js";
57
57
  import { HandleType, parseHandle } from "@vellumai/ces-contracts";
58
58
  import { buildLazyGetters, type ApiKeyRef } from "./managed-lazy-getters.js";
59
59
  import { MANAGED_LOCAL_STATIC_REJECTION_ERROR } from "./managed-errors.js";
60
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
60
61
  import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
61
62
  import { handleCredentialRoute, type CredentialRouteDeps } from "./http/credential-routes.js";
62
63
 
@@ -90,7 +91,7 @@ function ensureDataDirs(): void {
90
91
  // Build RPC handler registry (managed mode)
91
92
  // ---------------------------------------------------------------------------
92
93
 
93
- function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHandlerRegistry {
94
+ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef, secureKeyBackend: SecureKeyBackend): RpcHandlerRegistry {
94
95
  // -- Grant stores ----------------------------------------------------------
95
96
  const persistentGrantStore = new PersistentGrantStore(
96
97
  getCesGrantsDir("managed"),
@@ -110,7 +111,7 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
110
111
  // from the bootstrap handshake (the assistant forwards it after hatch).
111
112
  // We use a lazy getter so the handshake-provided key takes effect even
112
113
  // though handlers are built before the handshake completes.
113
- const platformBaseUrl = process.env["PLATFORM_BASE_URL"] ?? "";
114
+ const platformBaseUrl = process.env["VELLUM_PLATFORM_URL"] ?? "";
114
115
  const assistantId = process.env["PLATFORM_ASSISTANT_ID"] ?? "";
115
116
 
116
117
  const { getAssistantApiKey, getManagedSubjectOptions, getManagedMaterializerOptions } =
@@ -123,16 +124,15 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
123
124
 
124
125
  if (!platformBaseUrl || !assistantId) {
125
126
  warn(
126
- "PLATFORM_BASE_URL and/or PLATFORM_ASSISTANT_ID not set. " +
127
+ "VELLUM_PLATFORM_URL and/or PLATFORM_ASSISTANT_ID not set. " +
127
128
  "Managed credential materialisation will depend on the handshake-provided API key.",
128
129
  );
129
130
  }
130
131
 
131
132
  // -- Workspace root for command execution cwd ------------------------------
132
- // Prefer WORKSPACE_DIR when set (new Docker layout mounts workspace at
133
- // /workspace). Fall back to the legacy path derived from the assistant
134
- // data mount for backwards compatibility.
135
- const defaultWorkspaceDir = process.env["WORKSPACE_DIR"] ?? (() => {
133
+ // Use VELLUM_WORKSPACE_DIR when set, otherwise fall back to the legacy
134
+ // path derived from the assistant data mount.
135
+ const defaultWorkspaceDir = process.env["VELLUM_WORKSPACE_DIR"] ?? (() => {
136
136
  const assistantDataMount =
137
137
  process.env["CES_ASSISTANT_DATA_MOUNT"] ?? "/assistant-data-ro";
138
138
  return join(join(assistantDataMount, ".vellum"), "workspace");
@@ -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
  }
@@ -322,6 +322,27 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
322
322
  auditStore,
323
323
  }) as typeof handlers[string];
324
324
 
325
+ // Register credential CRUD handlers
326
+ handlers[CesRpcMethod.GetCredential] = (async (req: { account: string }) => {
327
+ const value = await secureKeyBackend.get(req.account);
328
+ return { found: value !== undefined, value };
329
+ }) as typeof handlers[string];
330
+
331
+ handlers[CesRpcMethod.SetCredential] = (async (req: { account: string; value: string }) => {
332
+ const ok = await secureKeyBackend.set(req.account, req.value);
333
+ return { ok };
334
+ }) as typeof handlers[string];
335
+
336
+ handlers[CesRpcMethod.DeleteCredential] = (async (req: { account: string }) => {
337
+ const result = await secureKeyBackend.delete(req.account);
338
+ return { result };
339
+ }) as typeof handlers[string];
340
+
341
+ handlers[CesRpcMethod.ListCredentials] = (async () => {
342
+ const accounts = await secureKeyBackend.list();
343
+ return { accounts };
344
+ }) as typeof handlers[string];
345
+
325
346
  return handlers;
326
347
  }
327
348
 
@@ -342,7 +363,7 @@ function startHealthServer(
342
363
  const url = new URL(req.url);
343
364
  if (url.pathname === "/healthz") {
344
365
  return new Response(
345
- JSON.stringify({ status: "ok", version: CES_PROTOCOL_VERSION }),
366
+ JSON.stringify({ status: "ok" }),
346
367
  { headers: { "Content-Type": "application/json" } },
347
368
  );
348
369
  }
@@ -354,7 +375,7 @@ function startHealthServer(
354
375
  // without a connection anyway, so readiness is purely about the
355
376
  // process being up and able to accept a future connection.
356
377
  return new Response(
357
- JSON.stringify({ ready: true, rpcConnected, version: CES_PROTOCOL_VERSION }),
378
+ JSON.stringify({ status: "ok", rpcConnected }),
358
379
  {
359
380
  status: 200,
360
381
  headers: { "Content-Type": "application/json" },
@@ -498,6 +519,14 @@ async function main(): Promise<void> {
498
519
  process.on("SIGTERM", shutdown);
499
520
  process.on("SIGINT", shutdown);
500
521
 
522
+ // Create the secure key backend unconditionally — it's needed by both
523
+ // HTTP credential routes (when CES_SERVICE_TOKEN is set) and RPC
524
+ // credential CRUD handlers (always available).
525
+ const assistantDataMount =
526
+ process.env["CES_ASSISTANT_DATA_MOUNT"] ?? "/assistant-data-ro";
527
+ const vellumRoot = join(assistantDataMount, ".vellum");
528
+ const secureKeyBackend = createLocalSecureKeyBackend(vellumRoot);
529
+
501
530
  // Set up credential CRUD routes if a service token is configured.
502
531
  // The assistant and gateway use CES_SERVICE_TOKEN to authenticate
503
532
  // credential management requests over HTTP.
@@ -505,11 +534,7 @@ async function main(): Promise<void> {
505
534
  let credentialDeps: CredentialRouteDeps | null = null;
506
535
 
507
536
  if (serviceToken) {
508
- const assistantDataMount =
509
- process.env["CES_ASSISTANT_DATA_MOUNT"] ?? "/assistant-data-ro";
510
- const vellumRoot = join(assistantDataMount, ".vellum");
511
- const backend = createLocalSecureKeyBackend(vellumRoot);
512
- credentialDeps = { backend, serviceToken };
537
+ credentialDeps = { backend: secureKeyBackend, serviceToken };
513
538
  log("Credential CRUD routes enabled (CES_SERVICE_TOKEN configured)");
514
539
  } else {
515
540
  warn(
@@ -545,7 +570,7 @@ async function main(): Promise<void> {
545
570
  // are available to handlers at call time (after the handshake completes).
546
571
  const sessionIdRef: SessionIdRef = { current: `ces-managed-${Date.now()}` };
547
572
  const apiKeyRef: ApiKeyRef = { current: "" };
548
- const handlers = buildHandlers(sessionIdRef, apiKeyRef);
573
+ const handlers = buildHandlers(sessionIdRef, apiKeyRef, secureKeyBackend);
549
574
 
550
575
  const server = new CesRpcServer({
551
576
  input: connection.readable,
@@ -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);