@vellumai/credential-executor 0.6.6 → 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.
- package/Dockerfile +1 -1
- package/bun.lock +15 -7
- package/node_modules/@vellumai/credential-storage/src/__tests__/package-boundary.test.ts +32 -6
- package/node_modules/@vellumai/egress-proxy/src/__tests__/package-boundary.test.ts +32 -1
- package/node_modules/@vellumai/{ces-contracts → service-contracts}/bun.lock +1 -1
- package/node_modules/@vellumai/{ces-contracts → service-contracts}/package.json +4 -2
- package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/__tests__/contracts.test.ts +5 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/package-boundary.test.ts +155 -0
- package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +23 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +25 -0
- package/node_modules/@vellumai/{ces-contracts/src/index.ts → service-contracts/src/transport.ts} +6 -28
- package/node_modules/@vellumai/service-contracts/src/trust-rules.ts +116 -0
- package/package.json +5 -4
- package/src/__tests__/bulk-set-credentials.test.ts +1 -1
- package/src/__tests__/ces-migrations-002-api-keys.test.ts +185 -0
- package/src/__tests__/ces-migrations-runner.test.ts +227 -0
- package/src/__tests__/cli.test.ts +139 -0
- package/src/__tests__/command-executor.test.ts +71 -42
- package/src/__tests__/http-executor.test.ts +1 -1
- package/src/__tests__/local-materializers.test.ts +1 -1
- package/src/__tests__/local-token-refresh.test.ts +65 -38
- package/src/__tests__/managed-integration.test.ts +1 -1
- package/src/__tests__/managed-lazy-getters.test.ts +1 -1
- package/src/__tests__/managed-materializers.test.ts +1 -1
- package/src/__tests__/managed-rejection.test.ts +1 -1
- package/src/__tests__/toolstore.test.ts +65 -20
- package/src/__tests__/transport.test.ts +13 -4
- package/src/audit/store.ts +2 -2
- package/src/cli.ts +158 -0
- package/src/commands/executor.ts +2 -2
- package/src/grants/rpc-handlers.ts +1 -1
- package/src/http/__tests__/credential-routes-normalization.test.ts +202 -0
- package/src/http/audit.ts +1 -1
- package/src/http/credential-routes.ts +53 -7
- package/src/http/executor.ts +2 -2
- package/src/http/policy.ts +1 -1
- package/src/main.ts +120 -50
- package/src/managed-errors.ts +2 -2
- package/src/managed-lazy-getters.ts +4 -4
- package/src/managed-main.ts +9 -3
- package/src/materializers/local-oauth-lookup.ts +7 -6
- package/src/materializers/local-token-refresh.ts +25 -15
- package/src/materializers/local.ts +1 -1
- package/src/migrations/001-no-op.ts +19 -0
- package/src/migrations/002-api-keys-to-credentials.ts +60 -0
- package/src/migrations/registry.ts +15 -0
- package/src/migrations/runner.ts +146 -0
- package/src/migrations/types.ts +54 -0
- package/src/paths.ts +15 -11
- package/src/server.ts +2 -2
- package/src/subjects/local.ts +2 -2
- package/src/subjects/managed.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/__tests__/trust-rules.test.ts +0 -471
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +0 -436
- /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/__tests__/grants.test.ts +0 -0
- /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/error.ts +0 -0
- /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/grants.ts +0 -0
- /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/handles.ts +0 -0
- /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/rendering.ts +0 -0
- /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/rpc.ts +0 -0
- /package/node_modules/@vellumai/{ces-contracts → service-contracts}/tsconfig.json +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
rmSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
symlinkSync,
|
|
9
|
+
} from "node:fs";
|
|
3
10
|
import { join } from "node:path";
|
|
4
11
|
import { tmpdir } from "node:os";
|
|
5
12
|
import { randomUUID } from "node:crypto";
|
|
@@ -47,11 +54,17 @@ function createTestArchive(
|
|
|
47
54
|
{ stdout: "pipe", stderr: "pipe" },
|
|
48
55
|
);
|
|
49
56
|
if (proc.exitCode !== 0) {
|
|
50
|
-
throw new Error(
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Failed to create test archive: ${new TextDecoder().decode(proc.stderr).trim()}`,
|
|
59
|
+
);
|
|
51
60
|
}
|
|
52
61
|
return Buffer.from(readFileSync(archivePath));
|
|
53
62
|
} finally {
|
|
54
|
-
try {
|
|
63
|
+
try {
|
|
64
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
65
|
+
} catch {
|
|
66
|
+
/* best effort */
|
|
67
|
+
}
|
|
55
68
|
}
|
|
56
69
|
}
|
|
57
70
|
|
|
@@ -78,11 +91,17 @@ function createSymlinkArchive(
|
|
|
78
91
|
{ stdout: "pipe", stderr: "pipe" },
|
|
79
92
|
);
|
|
80
93
|
if (proc.exitCode !== 0) {
|
|
81
|
-
throw new Error(
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Failed to create symlink test archive: ${new TextDecoder().decode(proc.stderr).trim()}`,
|
|
96
|
+
);
|
|
82
97
|
}
|
|
83
98
|
return Buffer.from(readFileSync(archivePath));
|
|
84
99
|
} finally {
|
|
85
|
-
try {
|
|
100
|
+
try {
|
|
101
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
102
|
+
} catch {
|
|
103
|
+
/* best effort */
|
|
104
|
+
}
|
|
86
105
|
}
|
|
87
106
|
}
|
|
88
107
|
|
|
@@ -93,7 +112,10 @@ const SAMPLE_BUNDLE_BYTES = createTestArchive("bin/test-cli");
|
|
|
93
112
|
const SAMPLE_BUNDLE_DIGEST = computeDigest(SAMPLE_BUNDLE_BYTES);
|
|
94
113
|
|
|
95
114
|
/** A different archive to test digest mismatches. */
|
|
96
|
-
const TAMPERED_BUNDLE_BYTES = createTestArchive(
|
|
115
|
+
const TAMPERED_BUNDLE_BYTES = createTestArchive(
|
|
116
|
+
"bin/test-cli",
|
|
117
|
+
"#!/usr/bin/env bash\nrm -rf /\n",
|
|
118
|
+
);
|
|
97
119
|
|
|
98
120
|
/**
|
|
99
121
|
* Build a minimal valid SecureCommandManifest for testing.
|
|
@@ -110,9 +132,7 @@ function buildSecureManifest(
|
|
|
110
132
|
commandProfiles: {
|
|
111
133
|
"read-data": {
|
|
112
134
|
description: "Read-only data access",
|
|
113
|
-
allowedArgvPatterns: [
|
|
114
|
-
{ name: "list", tokens: ["list", "<resource>"] },
|
|
115
|
-
],
|
|
135
|
+
allowedArgvPatterns: [{ name: "list", tokens: ["list", "<resource>"] }],
|
|
116
136
|
deniedSubcommands: ["admin"],
|
|
117
137
|
allowedNetworkTargets: [
|
|
118
138
|
{ hostPattern: "api.example.com", protocols: ["https"] },
|
|
@@ -159,7 +179,7 @@ beforeEach(() => {
|
|
|
159
179
|
mkdirSync(testTmpDir, { recursive: true });
|
|
160
180
|
|
|
161
181
|
// Point CES data root to the temp directory so tests are isolated
|
|
162
|
-
process.env["
|
|
182
|
+
process.env["CREDENTIAL_SECURITY_DIR"] = testTmpDir;
|
|
163
183
|
});
|
|
164
184
|
|
|
165
185
|
afterEach(() => {
|
|
@@ -168,7 +188,7 @@ afterEach(() => {
|
|
|
168
188
|
} catch {
|
|
169
189
|
// Best effort cleanup
|
|
170
190
|
}
|
|
171
|
-
delete process.env["
|
|
191
|
+
delete process.env["CREDENTIAL_SECURITY_DIR"];
|
|
172
192
|
});
|
|
173
193
|
|
|
174
194
|
// ---------------------------------------------------------------------------
|
|
@@ -280,7 +300,9 @@ describe("manifest validation helpers", () => {
|
|
|
280
300
|
});
|
|
281
301
|
|
|
282
302
|
test("rejects data: URL", () => {
|
|
283
|
-
const err = validateSourceUrl(
|
|
303
|
+
const err = validateSourceUrl(
|
|
304
|
+
"data:application/octet-stream;base64,AA==",
|
|
305
|
+
);
|
|
284
306
|
expect(err).not.toBeNull();
|
|
285
307
|
expect(err).toContain("data:");
|
|
286
308
|
});
|
|
@@ -375,7 +397,9 @@ describe("publishBundle — digest mismatch rejection", () => {
|
|
|
375
397
|
if (existsSync(toolstoreDir)) {
|
|
376
398
|
const { readdirSync } = require("node:fs");
|
|
377
399
|
const entries = readdirSync(toolstoreDir) as string[];
|
|
378
|
-
const stagingDirs = entries.filter((e: string) =>
|
|
400
|
+
const stagingDirs = entries.filter((e: string) =>
|
|
401
|
+
e.startsWith(".staging-"),
|
|
402
|
+
);
|
|
379
403
|
expect(stagingDirs).toHaveLength(0);
|
|
380
404
|
}
|
|
381
405
|
});
|
|
@@ -450,7 +474,10 @@ describe("publishBundle — immutable and deduplicated by digest", () => {
|
|
|
450
474
|
expect(firstResult.success).toBe(true);
|
|
451
475
|
|
|
452
476
|
// Publish a second, different bundle (real tar.gz archive)
|
|
453
|
-
const otherBytes = createTestArchive(
|
|
477
|
+
const otherBytes = createTestArchive(
|
|
478
|
+
"bin/test-cli",
|
|
479
|
+
"#!/usr/bin/env bash\necho other\n",
|
|
480
|
+
);
|
|
454
481
|
const otherDigest = computeDigest(otherBytes);
|
|
455
482
|
const otherManifest = buildSecureManifest({
|
|
456
483
|
bundleDigest: otherDigest,
|
|
@@ -464,7 +491,8 @@ describe("publishBundle — immutable and deduplicated by digest", () => {
|
|
|
464
491
|
expectedDigest: otherDigest,
|
|
465
492
|
bundleId: "other-cli",
|
|
466
493
|
version: "2.0.0",
|
|
467
|
-
sourceUrl:
|
|
494
|
+
sourceUrl:
|
|
495
|
+
"https://releases.example.com/other-cli/v2.0.0/bundle.tar.gz",
|
|
468
496
|
secureCommandManifest: otherManifest,
|
|
469
497
|
}),
|
|
470
498
|
);
|
|
@@ -631,7 +659,9 @@ describe("publishBundle — symlink escape prevention", () => {
|
|
|
631
659
|
// Create a real entrypoint
|
|
632
660
|
const entrypointPath = join(stagingDir, "bin/test-cli");
|
|
633
661
|
mkdirSync(join(stagingDir, "bin"), { recursive: true });
|
|
634
|
-
writeFileSync(entrypointPath, "#!/usr/bin/env bash\necho hello\n", {
|
|
662
|
+
writeFileSync(entrypointPath, "#!/usr/bin/env bash\necho hello\n", {
|
|
663
|
+
mode: 0o755,
|
|
664
|
+
});
|
|
635
665
|
|
|
636
666
|
// Create a symlink that escapes
|
|
637
667
|
symlinkSync("/etc/passwd", join(stagingDir, "bin/evil-link"));
|
|
@@ -663,16 +693,27 @@ describe("publishBundle — symlink escape prevention", () => {
|
|
|
663
693
|
expect(result.error).toContain("symlink");
|
|
664
694
|
expect(result.error).toContain("outside the bundle directory");
|
|
665
695
|
} finally {
|
|
666
|
-
try {
|
|
696
|
+
try {
|
|
697
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
698
|
+
} catch {
|
|
699
|
+
/* best effort */
|
|
700
|
+
}
|
|
667
701
|
}
|
|
668
702
|
});
|
|
669
703
|
|
|
670
704
|
test("accepts bundle with internal symlinks (not escaping)", () => {
|
|
671
705
|
// Create an archive with a symlink that points within the bundle
|
|
672
|
-
const stagingDir = join(
|
|
706
|
+
const stagingDir = join(
|
|
707
|
+
tmpdir(),
|
|
708
|
+
`ces-test-internal-symlink-${randomUUID()}`,
|
|
709
|
+
);
|
|
673
710
|
try {
|
|
674
711
|
mkdirSync(join(stagingDir, "bin"), { recursive: true });
|
|
675
|
-
writeFileSync(
|
|
712
|
+
writeFileSync(
|
|
713
|
+
join(stagingDir, "bin/test-cli"),
|
|
714
|
+
"#!/usr/bin/env bash\necho hello\n",
|
|
715
|
+
{ mode: 0o755 },
|
|
716
|
+
);
|
|
676
717
|
// Create a symlink within the bundle: bin/alias -> test-cli (relative)
|
|
677
718
|
symlinkSync("test-cli", join(stagingDir, "bin/alias"));
|
|
678
719
|
|
|
@@ -701,7 +742,11 @@ describe("publishBundle — symlink escape prevention", () => {
|
|
|
701
742
|
|
|
702
743
|
expect(result.success).toBe(true);
|
|
703
744
|
} finally {
|
|
704
|
-
try {
|
|
745
|
+
try {
|
|
746
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
747
|
+
} catch {
|
|
748
|
+
/* best effort */
|
|
749
|
+
}
|
|
705
750
|
}
|
|
706
751
|
});
|
|
707
752
|
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
CES_PROTOCOL_VERSION,
|
|
22
22
|
type HandshakeAck,
|
|
23
23
|
type RpcEnvelope,
|
|
24
|
-
} from "@vellumai/
|
|
24
|
+
} from "@vellumai/service-contracts/credential-rpc";
|
|
25
25
|
|
|
26
26
|
import { getCesDataRoot, getBootstrapSocketPath, getHealthPort } from "../paths.js";
|
|
27
27
|
import { CesRpcServer, type RpcHandlerRegistry } from "../server.js";
|
|
@@ -393,16 +393,25 @@ describe("CES data paths", () => {
|
|
|
393
393
|
});
|
|
394
394
|
|
|
395
395
|
test("getBootstrapSocketPath respects CES_BOOTSTRAP_SOCKET env var", () => {
|
|
396
|
-
const
|
|
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 (
|
|
402
|
-
process.env["CES_BOOTSTRAP_SOCKET"] =
|
|
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/audit/store.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Persists token-free audit record summaries to `audit.jsonl` inside
|
|
5
5
|
* the CES-private data root. Each line is a self-contained JSON object
|
|
6
|
-
* conforming to the `AuditRecordSummary` schema from `@vellumai/
|
|
6
|
+
* conforming to the `AuditRecordSummary` schema from `@vellumai/service-contracts`.
|
|
7
7
|
*
|
|
8
8
|
* Design principles:
|
|
9
9
|
* - **Append-only**: Records are appended one per line. The file is never
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from "node:fs";
|
|
28
28
|
import { dirname, join } from "node:path";
|
|
29
29
|
|
|
30
|
-
import type { AuditRecordSummary } from "@vellumai/
|
|
30
|
+
import type { AuditRecordSummary } from "@vellumai/service-contracts/credential-rpc";
|
|
31
31
|
|
|
32
32
|
import { getCesAuditDir } from "../paths.js";
|
|
33
33
|
|
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
|
+
});
|
package/src/commands/executor.ts
CHANGED
|
@@ -74,7 +74,7 @@ import {
|
|
|
74
74
|
type WorkspaceOutput,
|
|
75
75
|
type CopybackResult,
|
|
76
76
|
} from "./workspace.js";
|
|
77
|
-
import { hashProposal, type AuditRecordSummary, type CommandGrantProposal } from "@vellumai/
|
|
77
|
+
import { hashProposal, type AuditRecordSummary, type CommandGrantProposal } from "@vellumai/service-contracts/credential-rpc";
|
|
78
78
|
|
|
79
79
|
import type { AuditStore } from "../audit/store.js";
|
|
80
80
|
import type { PersistentGrantStore } from "../grants/persistent-store.js";
|
|
@@ -737,7 +737,7 @@ function checkGrant(
|
|
|
737
737
|
|
|
738
738
|
// Check temporary grants — build the same proposal shape that the
|
|
739
739
|
// approval bridge produces, then hash with the canonical algorithm
|
|
740
|
-
// from `@vellumai/
|
|
740
|
+
// from `@vellumai/service-contracts` so the hashes align.
|
|
741
741
|
const tempProposal: CommandGrantProposal = {
|
|
742
742
|
type: "command",
|
|
743
743
|
credentialHandle: request.credentialHandle,
|
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
ListAuditRecords,
|
|
24
24
|
ListAuditRecordsResponse,
|
|
25
25
|
PersistentGrantRecord,
|
|
26
|
-
} from "@vellumai/
|
|
26
|
+
} from "@vellumai/service-contracts/credential-rpc";
|
|
27
27
|
|
|
28
28
|
import type { PersistentGrantStore, PersistentGrant } from "./persistent-store.js";
|
|
29
29
|
import type { TemporaryGrantStore } from "./temporary-store.js";
|
|
@@ -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
|
+
});
|
package/src/http/audit.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { randomUUID } from "node:crypto";
|
|
17
17
|
|
|
18
|
-
import type { AuditRecordSummary } from "@vellumai/
|
|
18
|
+
import type { AuditRecordSummary } from "@vellumai/service-contracts/credential-rpc";
|
|
19
19
|
import { derivePathTemplate } from "./path-template.js";
|
|
20
20
|
|
|
21
21
|
// ---------------------------------------------------------------------------
|