@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.
- package/node_modules/@vellumai/ces-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/rpc.ts +120 -1
- package/node_modules/@vellumai/credential-storage/package.json +1 -0
- package/node_modules/@vellumai/egress-proxy/package.json +1 -0
- package/package.json +2 -1
- package/src/__tests__/local-secure-key-backend.test.ts +156 -0
- package/src/__tests__/managed-integration.test.ts +316 -5
- package/src/main.ts +21 -0
- package/src/managed-main.ts +41 -16
- package/src/materializers/local-secure-key-backend.ts +73 -12
|
@@ -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
|
|
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
|
|
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;
|
package/package.json
CHANGED
|
@@ -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"
|
|
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({
|
|
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.
|
|
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
|
|
package/src/managed-main.ts
CHANGED
|
@@ -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["
|
|
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
|
-
"
|
|
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
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
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
|
-
"
|
|
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"
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
if (!store) return false;
|
|
292
|
-
|
|
345
|
+
let store: StoreFile;
|
|
293
346
|
let aesKey: Buffer;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
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);
|