@vellumai/credential-executor 0.7.0 → 0.7.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.
@@ -393,16 +393,25 @@ describe("CES data paths", () => {
393
393
  });
394
394
 
395
395
  test("getBootstrapSocketPath respects CES_BOOTSTRAP_SOCKET env var", () => {
396
- const saved = process.env["CES_BOOTSTRAP_SOCKET"];
396
+ const savedSocket = process.env["CES_BOOTSTRAP_SOCKET"];
397
+ const savedDir = process.env["CES_BOOTSTRAP_SOCKET_DIR"];
398
+ // CES_BOOTSTRAP_SOCKET_DIR takes precedence; clear it so the
399
+ // CES_BOOTSTRAP_SOCKET fallback is actually exercised.
400
+ delete process.env["CES_BOOTSTRAP_SOCKET_DIR"];
397
401
  process.env["CES_BOOTSTRAP_SOCKET"] = "/tmp/test-ces.sock";
398
402
  try {
399
403
  expect(getBootstrapSocketPath()).toBe("/tmp/test-ces.sock");
400
404
  } finally {
401
- if (saved !== undefined) {
402
- process.env["CES_BOOTSTRAP_SOCKET"] = saved;
405
+ if (savedSocket !== undefined) {
406
+ process.env["CES_BOOTSTRAP_SOCKET"] = savedSocket;
403
407
  } else {
404
408
  delete process.env["CES_BOOTSTRAP_SOCKET"];
405
409
  }
410
+ if (savedDir !== undefined) {
411
+ process.env["CES_BOOTSTRAP_SOCKET_DIR"] = savedDir;
412
+ } else {
413
+ delete process.env["CES_BOOTSTRAP_SOCKET_DIR"];
414
+ }
406
415
  }
407
416
  });
408
417
  });
package/src/cli.ts ADDED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * CES CLI — lightweight credential CRUD for the CES container.
4
+ *
5
+ * Operates directly on the encrypted key store (`keys.enc` + `store.key`)
6
+ * without requiring the RPC server, HTTP routes, or a running assistant.
7
+ *
8
+ * Usage:
9
+ * ces list
10
+ * ces get <account>
11
+ * ces set <account> <value>
12
+ * ces delete <account>
13
+ *
14
+ * Account format: `credential/<service>/<field>` (e.g. `credential/vellum/platform_organization_id`)
15
+ *
16
+ * Environment variables:
17
+ * CREDENTIAL_SECURITY_DIR — directory containing `keys.enc` + `store.key`
18
+ * CES_ASSISTANT_DATA_MOUNT — fallback root for `<mount>/.vellum/protected/`
19
+ *
20
+ * When neither is set, defaults to `~/.vellum/protected/` (local mode).
21
+ */
22
+
23
+ import { join } from "node:path";
24
+ import { homedir } from "node:os";
25
+ import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Path resolution (mirrors managed-main.ts)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function resolveVellumRoot(): string {
32
+ const secDir = process.env["CREDENTIAL_SECURITY_DIR"]?.trim();
33
+ if (secDir) {
34
+ // CREDENTIAL_SECURITY_DIR points directly at the dir containing
35
+ // keys.enc, but createLocalSecureKeyBackend wants the parent
36
+ // (.vellum root) and appends /protected/ itself — unless
37
+ // CREDENTIAL_SECURITY_DIR is set, in which case the backend reads
38
+ // from that dir directly. So we pass dirname(secDir) as vellumRoot.
39
+ // Actually, looking at resolveSecurityDir(): if CREDENTIAL_SECURITY_DIR
40
+ // is set it uses that directly, ignoring vellumRoot. So vellumRoot
41
+ // can be anything — the env var takes precedence.
42
+ return join(secDir, "..");
43
+ }
44
+
45
+ const mount = process.env["CES_ASSISTANT_DATA_MOUNT"]?.trim();
46
+ if (mount) {
47
+ return join(mount, ".vellum");
48
+ }
49
+
50
+ return join(homedir(), ".vellum");
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // CLI
55
+ // ---------------------------------------------------------------------------
56
+
57
+ async function main(): Promise<void> {
58
+ const [command, ...args] = process.argv.slice(2);
59
+
60
+ if (!command || command === "--help" || command === "-h") {
61
+ printUsage();
62
+ process.exit(0);
63
+ }
64
+
65
+ const vellumRoot = resolveVellumRoot();
66
+ const backend = createLocalSecureKeyBackend(vellumRoot);
67
+
68
+ switch (command) {
69
+ case "list": {
70
+ const accounts = await backend.list();
71
+ if (accounts.length === 0) {
72
+ console.log("(no credentials stored)");
73
+ } else {
74
+ for (const account of accounts.sort()) {
75
+ console.log(account);
76
+ }
77
+ }
78
+ break;
79
+ }
80
+
81
+ case "get": {
82
+ const account = args[0];
83
+ if (!account) {
84
+ console.error("Usage: ces get <account>");
85
+ process.exit(1);
86
+ }
87
+ const value = await backend.get(account);
88
+ if (value === undefined) {
89
+ console.error(`Not found: ${account}`);
90
+ process.exit(1);
91
+ }
92
+ // Write raw value to stdout (no trailing newline for piping)
93
+ process.stdout.write(value);
94
+ break;
95
+ }
96
+
97
+ case "set": {
98
+ const account = args[0];
99
+ const value = args[1];
100
+ if (!account || value === undefined) {
101
+ console.error("Usage: ces set <account> <value>");
102
+ process.exit(1);
103
+ }
104
+ const ok = await backend.set(account, value);
105
+ if (ok) {
106
+ console.log(`Set: ${account}`);
107
+ } else {
108
+ console.error(`Failed to set: ${account}`);
109
+ process.exit(1);
110
+ }
111
+ break;
112
+ }
113
+
114
+ case "delete": {
115
+ const account = args[0];
116
+ if (!account) {
117
+ console.error("Usage: ces delete <account>");
118
+ process.exit(1);
119
+ }
120
+ const result = await backend.delete(account);
121
+ if (result === "deleted") {
122
+ console.log(`Deleted: ${account}`);
123
+ } else {
124
+ console.error(`Not found: ${account}`);
125
+ process.exit(1);
126
+ }
127
+ break;
128
+ }
129
+
130
+ default:
131
+ console.error(`Unknown command: ${command}`);
132
+ printUsage();
133
+ process.exit(1);
134
+ }
135
+ }
136
+
137
+ function printUsage(): void {
138
+ console.log(`CES CLI — credential CRUD for the encrypted key store
139
+
140
+ Usage:
141
+ ces list List all credential accounts
142
+ ces get <account> Get a credential value
143
+ ces set <account> <value> Set a credential value
144
+ ces delete <account> Delete a credential
145
+
146
+ Account format:
147
+ credential/<service>/<field>
148
+ Example: credential/vellum/platform_organization_id
149
+
150
+ Environment:
151
+ CREDENTIAL_SECURITY_DIR Directory containing keys.enc + store.key
152
+ CES_ASSISTANT_DATA_MOUNT Fallback: <mount>/.vellum/protected/`);
153
+ }
154
+
155
+ main().catch((err) => {
156
+ console.error(`Fatal: ${err instanceof Error ? err.message : err}`);
157
+ process.exit(1);
158
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Tests for credential account key normalization in the HTTP routes.
3
+ *
4
+ * Verifies that colon-separated account names (e.g. `vellum:platform_organization_id`)
5
+ * are transparently normalized to the internal slash-separated format
6
+ * (e.g. `credential/vellum/platform_organization_id`) so that credentials
7
+ * stored via direct HTTP are findable by the gateway and assistant.
8
+ */
9
+
10
+ import { describe, it, expect } from "bun:test";
11
+
12
+ import { handleCredentialRoute } from "../credential-routes.js";
13
+ import type { CredentialRouteDeps } from "../credential-routes.js";
14
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const SERVICE_TOKEN = "test-token-normalization";
21
+
22
+ function makeDeps(): { deps: CredentialRouteDeps; store: Map<string, string> } {
23
+ const store = new Map<string, string>();
24
+ const backend: SecureKeyBackend = {
25
+ get: async (account: string) => store.get(account),
26
+ set: async (account: string, value: string) => {
27
+ store.set(account, value);
28
+ return true;
29
+ },
30
+ delete: async (account: string) => {
31
+ if (!store.has(account)) return "not-found";
32
+ store.delete(account);
33
+ return "deleted";
34
+ },
35
+ list: async () => [...store.keys()],
36
+ };
37
+ return { deps: { backend, serviceToken: SERVICE_TOKEN }, store };
38
+ }
39
+
40
+ function makeRequest(
41
+ method: string,
42
+ path: string,
43
+ body?: unknown,
44
+ ): Request {
45
+ const url = `http://localhost:8090${path}`;
46
+ const headers: Record<string, string> = {
47
+ "Content-Type": "application/json",
48
+ Authorization: `Bearer ${SERVICE_TOKEN}`,
49
+ };
50
+ const init: RequestInit = { method, headers };
51
+ if (body !== undefined) {
52
+ init.body = JSON.stringify(body);
53
+ }
54
+ return new Request(url, init);
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Tests
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe("credential route key normalization", () => {
62
+ it("normalizes colon-separated key on POST (set)", async () => {
63
+ const { deps, store } = makeDeps();
64
+
65
+ const req = makeRequest(
66
+ "POST",
67
+ "/v1/credentials/vellum%3Aplatform_organization_id",
68
+ { value: "org-uuid-123" },
69
+ );
70
+ const res = await handleCredentialRoute(req, deps);
71
+
72
+ expect(res).not.toBeNull();
73
+ expect(res!.status).toBe(200);
74
+ const body = await res!.json();
75
+ expect(body.ok).toBe(true);
76
+ expect(body.account).toBe("credential/vellum/platform_organization_id");
77
+
78
+ // Verify it was stored under the normalized key
79
+ expect(store.get("credential/vellum/platform_organization_id")).toBe("org-uuid-123");
80
+ expect(store.has("vellum:platform_organization_id")).toBe(false);
81
+ });
82
+
83
+ it("normalizes colon-separated key on GET", async () => {
84
+ const { deps, store } = makeDeps();
85
+ store.set("credential/vellum/platform_user_id", "user-uuid-456");
86
+
87
+ const req = makeRequest(
88
+ "GET",
89
+ "/v1/credentials/vellum%3Aplatform_user_id",
90
+ );
91
+ const res = await handleCredentialRoute(req, deps);
92
+
93
+ expect(res).not.toBeNull();
94
+ expect(res!.status).toBe(200);
95
+ const body = await res!.json();
96
+ expect(body.value).toBe("user-uuid-456");
97
+ });
98
+
99
+ it("normalizes colon-separated key on DELETE", async () => {
100
+ const { deps, store } = makeDeps();
101
+ store.set("credential/vellum/temp_cred", "temp-value");
102
+
103
+ const req = makeRequest(
104
+ "DELETE",
105
+ "/v1/credentials/vellum%3Atemp_cred",
106
+ );
107
+ const res = await handleCredentialRoute(req, deps);
108
+
109
+ expect(res).not.toBeNull();
110
+ expect(res!.status).toBe(200);
111
+ expect(store.has("credential/vellum/temp_cred")).toBe(false);
112
+ });
113
+
114
+ it("passes through keys already in credential/ format", async () => {
115
+ const { deps, store } = makeDeps();
116
+
117
+ const req = makeRequest(
118
+ "POST",
119
+ "/v1/credentials/credential%2Fvellum%2Fassistant_api_key",
120
+ { value: "api-key-789" },
121
+ );
122
+ const res = await handleCredentialRoute(req, deps);
123
+
124
+ expect(res).not.toBeNull();
125
+ expect(res!.status).toBe(200);
126
+ expect(store.get("credential/vellum/assistant_api_key")).toBe("api-key-789");
127
+ });
128
+
129
+ it("passes through oauth/ prefixed keys", async () => {
130
+ const { deps, store } = makeDeps();
131
+
132
+ const req = makeRequest(
133
+ "POST",
134
+ "/v1/credentials/oauth%2Fconnection%2Faccess_token",
135
+ { value: "token-abc" },
136
+ );
137
+ const res = await handleCredentialRoute(req, deps);
138
+
139
+ expect(res).not.toBeNull();
140
+ expect(res!.status).toBe(200);
141
+ expect(store.get("oauth/connection/access_token")).toBe("token-abc");
142
+ });
143
+
144
+ it("normalizes colon-separated keys in bulk set", async () => {
145
+ const { deps, store } = makeDeps();
146
+
147
+ const req = makeRequest("POST", "/v1/credentials/bulk", {
148
+ credentials: [
149
+ { account: "vellum:platform_organization_id", value: "org-1" },
150
+ { account: "vellum:platform_user_id", value: "user-1" },
151
+ { account: "credential/vellum/assistant_api_key", value: "key-1" },
152
+ ],
153
+ });
154
+ const res = await handleCredentialRoute(req, deps);
155
+
156
+ expect(res).not.toBeNull();
157
+ expect(res!.status).toBe(200);
158
+ const body = await res!.json();
159
+
160
+ expect(body.results).toHaveLength(3);
161
+ expect(body.results[0].account).toBe("credential/vellum/platform_organization_id");
162
+ expect(body.results[1].account).toBe("credential/vellum/platform_user_id");
163
+ expect(body.results[2].account).toBe("credential/vellum/assistant_api_key");
164
+
165
+ expect(store.get("credential/vellum/platform_organization_id")).toBe("org-1");
166
+ expect(store.get("credential/vellum/platform_user_id")).toBe("user-1");
167
+ expect(store.get("credential/vellum/assistant_api_key")).toBe("key-1");
168
+ });
169
+
170
+ it("splits multi-colon keys at the last colon", async () => {
171
+ const { deps, store } = makeDeps();
172
+
173
+ const req = makeRequest(
174
+ "POST",
175
+ "/v1/credentials/integration%3Agoogle%3Aaccess_token",
176
+ { value: "google-token" },
177
+ );
178
+ const res = await handleCredentialRoute(req, deps);
179
+
180
+ expect(res).not.toBeNull();
181
+ expect(res!.status).toBe(200);
182
+ const body = await res!.json();
183
+ // "integration:google:access_token" splits at last colon →
184
+ // service="integration:google", field="access_token"
185
+ expect(body.account).toBe("credential/integration:google/access_token");
186
+ expect(store.get("credential/integration:google/access_token")).toBe("google-token");
187
+ });
188
+
189
+ it("returns normalized key in response body", async () => {
190
+ const { deps } = makeDeps();
191
+
192
+ const req = makeRequest(
193
+ "POST",
194
+ "/v1/credentials/slack_channel%3Abot_token",
195
+ { value: "xoxb-test" },
196
+ );
197
+ const res = await handleCredentialRoute(req, deps);
198
+
199
+ const body = await res!.json();
200
+ expect(body.account).toBe("credential/slack_channel/bot_token");
201
+ });
202
+ });
@@ -21,6 +21,49 @@ import { timingSafeEqual } from "node:crypto";
21
21
 
22
22
  import type { SecureKeyBackend } from "@vellumai/credential-storage";
23
23
 
24
+ // ---------------------------------------------------------------------------
25
+ // Account key normalization
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Known internal key prefixes. Keys in the encrypted store use slash-separated
30
+ * paths (e.g. `credential/vellum/platform_organization_id`), but callers
31
+ * (especially manual `curl` invocations) often use the colon-separated format
32
+ * visible in the CLI (e.g. `vellum:platform_organization_id`).
33
+ *
34
+ * This normalizer transparently converts colon-separated credential names
35
+ * to the internal format so writes land under the correct key. Without this,
36
+ * a credential stored as `vellum:platform_organization_id` would silently
37
+ * succeed but be invisible to the gateway and assistant, which look up
38
+ * `credential/vellum/platform_organization_id`.
39
+ */
40
+ const CREDENTIAL_PREFIX = "credential/";
41
+
42
+ function normalizeAccountKey(account: string): string {
43
+ // Already in internal format — pass through
44
+ if (account.startsWith(CREDENTIAL_PREFIX)) {
45
+ return account;
46
+ }
47
+
48
+ // Other known internal prefixes — pass through as-is
49
+ if (account.startsWith("oauth/")) {
50
+ return account;
51
+ }
52
+
53
+ // Convert "service:field" → "credential/service/field"
54
+ // Use lastIndexOf to match the canonical split in secret-routes.ts
55
+ // (e.g. "integration:google:access_token" → service="integration:google", field="access_token")
56
+ const colonIdx = account.lastIndexOf(":");
57
+ if (colonIdx > 0 && colonIdx < account.length - 1) {
58
+ const service = account.slice(0, colonIdx);
59
+ const field = account.slice(colonIdx + 1);
60
+ return `${CREDENTIAL_PREFIX}${service}/${field}`;
61
+ }
62
+
63
+ // Unrecognized format — return as-is (will likely fail lookup, which is fine)
64
+ return account;
65
+ }
66
+
24
67
  // ---------------------------------------------------------------------------
25
68
  // Auth
26
69
  // ---------------------------------------------------------------------------
@@ -136,8 +179,9 @@ export async function handleCredentialRoute(
136
179
 
137
180
  const results: Array<{ account: string; ok: boolean }> = [];
138
181
  for (const entry of body.credentials as Array<{ account: string; value: string }>) {
139
- const ok = await backend.set(entry.account, entry.value);
140
- results.push({ account: entry.account, ok: !!ok });
182
+ const normalized = normalizeAccountKey(entry.account);
183
+ const ok = await backend.set(normalized, entry.value);
184
+ results.push({ account: normalized, ok: !!ok });
141
185
  }
142
186
 
143
187
  return new Response(
@@ -167,15 +211,17 @@ export async function handleCredentialRoute(
167
211
  return null; // Not a credential route
168
212
  }
169
213
 
170
- const account = decodeURIComponent(accountSegment.slice(1));
171
- if (!account) {
214
+ const rawAccount = decodeURIComponent(accountSegment.slice(1));
215
+ if (!rawAccount) {
172
216
  return new Response(
173
217
  JSON.stringify({ error: "Account name is required" }),
174
218
  { status: 400, headers: { "Content-Type": "application/json" } },
175
- );
176
- }
219
+ );
220
+ }
221
+
222
+ const account = normalizeAccountKey(rawAccount);
177
223
 
178
- switch (req.method) {
224
+ switch (req.method) {
179
225
  // GET /v1/credentials/:account — get credential value
180
226
  case "GET": {
181
227
  const value = await backend.get(account);