@vellumai/credential-executor 0.4.55

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.
Files changed (42) hide show
  1. package/Dockerfile +55 -0
  2. package/bun.lock +37 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/command-executor.test.ts +1333 -0
  5. package/src/__tests__/command-validator.test.ts +708 -0
  6. package/src/__tests__/command-workspace.test.ts +997 -0
  7. package/src/__tests__/grant-store.test.ts +467 -0
  8. package/src/__tests__/http-executor.test.ts +1251 -0
  9. package/src/__tests__/http-policy.test.ts +970 -0
  10. package/src/__tests__/local-materializers.test.ts +826 -0
  11. package/src/__tests__/managed-materializers.test.ts +961 -0
  12. package/src/__tests__/toolstore.test.ts +539 -0
  13. package/src/__tests__/transport.test.ts +388 -0
  14. package/src/audit/store.ts +188 -0
  15. package/src/commands/auth-adapters.ts +169 -0
  16. package/src/commands/executor.ts +840 -0
  17. package/src/commands/output-scan.ts +157 -0
  18. package/src/commands/profiles.ts +282 -0
  19. package/src/commands/validator.ts +438 -0
  20. package/src/commands/workspace.ts +512 -0
  21. package/src/grants/index.ts +17 -0
  22. package/src/grants/persistent-store.ts +247 -0
  23. package/src/grants/rpc-handlers.ts +269 -0
  24. package/src/grants/temporary-store.ts +219 -0
  25. package/src/http/audit.ts +84 -0
  26. package/src/http/executor.ts +540 -0
  27. package/src/http/path-template.ts +179 -0
  28. package/src/http/policy.ts +256 -0
  29. package/src/http/response-filter.ts +233 -0
  30. package/src/index.ts +106 -0
  31. package/src/main.ts +263 -0
  32. package/src/managed-main.ts +420 -0
  33. package/src/materializers/local.ts +300 -0
  34. package/src/materializers/managed-platform.ts +270 -0
  35. package/src/paths.ts +137 -0
  36. package/src/server.ts +636 -0
  37. package/src/subjects/local.ts +177 -0
  38. package/src/subjects/managed.ts +290 -0
  39. package/src/toolstore/integrity.ts +94 -0
  40. package/src/toolstore/manifest.ts +154 -0
  41. package/src/toolstore/publish.ts +342 -0
  42. package/tsconfig.json +20 -0
@@ -0,0 +1,300 @@
1
+ /**
2
+ * CES local credential materialisation.
3
+ *
4
+ * Materialises credential values from local storage into per-operation
5
+ * results that the CES execution layer can inject into authenticated
6
+ * requests or commands. Materialised values never persist to assistant-
7
+ * visible state — they exist only for the duration of the execution.
8
+ *
9
+ * Supports two credential types:
10
+ *
11
+ * - **Static secrets** — Retrieved from the secure-key backend using the
12
+ * storage key from the resolved subject. Fails if the key is missing.
13
+ *
14
+ * - **OAuth tokens** — Retrieved from the secure-key backend using the
15
+ * connection's access token path. Automatically refreshes expired tokens
16
+ * using the shared `@vellumai/credential-storage` refresh primitives.
17
+ * Fails if no access token exists (disconnected connection) or if
18
+ * refresh fails.
19
+ *
20
+ * Materialisation is fail-closed: missing keys, disconnected connections,
21
+ * and refresh failures all return errors before any outbound work starts.
22
+ */
23
+
24
+ import {
25
+ type SecureKeyBackend,
26
+ type TokenRefreshResult,
27
+ getStoredAccessToken,
28
+ getStoredRefreshToken,
29
+ isTokenExpired,
30
+ RefreshCircuitBreaker,
31
+ RefreshDeduplicator,
32
+ persistRefreshedTokens,
33
+ } from "@vellumai/credential-storage";
34
+ import { HandleType } from "@vellumai/ces-contracts";
35
+
36
+ import type {
37
+ ResolvedLocalSubject,
38
+ ResolvedOAuthSubject,
39
+ ResolvedStaticSubject,
40
+ } from "../subjects/local.js";
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Materialisation result
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * A materialised credential value ready for injection into an execution
48
+ * environment. The value is ephemeral and must not be persisted to any
49
+ * assistant-visible store.
50
+ */
51
+ export interface MaterialisedCredential {
52
+ /** The credential value (secret, token, etc.). */
53
+ value: string;
54
+ /** The handle type that produced this value. */
55
+ handleType: HandleType;
56
+ /** For OAuth: the token expiry timestamp (null if unknown). */
57
+ expiresAt?: number | null;
58
+ }
59
+
60
+ export type MaterialisationResult =
61
+ | { ok: true; credential: MaterialisedCredential }
62
+ | { ok: false; error: string };
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Token refresh callback
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Callback for performing the actual OAuth token refresh network call.
70
+ *
71
+ * CES delegates the refresh network call to callers so it remains
72
+ * transport-agnostic. The callback receives the connection ID and
73
+ * refresh token, and returns a `TokenRefreshResult` from the shared
74
+ * credential-storage primitives.
75
+ */
76
+ export type TokenRefreshFn = (
77
+ connectionId: string,
78
+ refreshToken: string,
79
+ ) => Promise<TokenRefreshResult>;
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Local materialiser
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export interface LocalMaterialiserDeps {
86
+ /** Secure-key backend for retrieving secret values. */
87
+ secureKeyBackend: SecureKeyBackend;
88
+ /** Optional token refresh callback for OAuth tokens. */
89
+ tokenRefreshFn?: TokenRefreshFn;
90
+ }
91
+
92
+ /**
93
+ * Local credential materialiser.
94
+ *
95
+ * Stateful: maintains a per-connection circuit breaker and refresh
96
+ * deduplicator for OAuth token refresh. Create one instance per CES
97
+ * process lifetime.
98
+ */
99
+ export class LocalMaterialiser {
100
+ private readonly backend: SecureKeyBackend;
101
+ private readonly tokenRefreshFn?: TokenRefreshFn;
102
+ private readonly circuitBreaker = new RefreshCircuitBreaker();
103
+ private readonly deduplicator = new RefreshDeduplicator();
104
+
105
+ constructor(deps: LocalMaterialiserDeps) {
106
+ this.backend = deps.secureKeyBackend;
107
+ this.tokenRefreshFn = deps.tokenRefreshFn;
108
+ }
109
+
110
+ /**
111
+ * Materialise a resolved local subject into a credential value.
112
+ *
113
+ * Dispatches to the appropriate handler based on the subject type.
114
+ * Returns a discriminated result — never throws for expected failure
115
+ * modes (missing keys, disconnected connections, expired tokens).
116
+ */
117
+ async materialise(
118
+ subject: ResolvedLocalSubject,
119
+ ): Promise<MaterialisationResult> {
120
+ switch (subject.type) {
121
+ case HandleType.LocalStatic:
122
+ return this.materialiseStatic(subject);
123
+ case HandleType.LocalOAuth:
124
+ return this.materialiseOAuth(subject);
125
+ default:
126
+ return {
127
+ ok: false,
128
+ error: `Unsupported subject type for local materialisation`,
129
+ };
130
+ }
131
+ }
132
+
133
+ // -----------------------------------------------------------------------
134
+ // Static secret materialisation
135
+ // -----------------------------------------------------------------------
136
+
137
+ private async materialiseStatic(
138
+ subject: ResolvedStaticSubject,
139
+ ): Promise<MaterialisationResult> {
140
+ const secretValue = await this.backend.get(subject.storageKey);
141
+ if (secretValue === undefined) {
142
+ return {
143
+ ok: false,
144
+ error: `Secure key "${subject.storageKey}" not found in local credential store. ` +
145
+ `The credential for service="${subject.metadata.service}", field="${subject.metadata.field}" ` +
146
+ `has metadata but no secret value stored.`,
147
+ };
148
+ }
149
+
150
+ return {
151
+ ok: true,
152
+ credential: {
153
+ value: secretValue,
154
+ handleType: HandleType.LocalStatic,
155
+ },
156
+ };
157
+ }
158
+
159
+ // -----------------------------------------------------------------------
160
+ // OAuth token materialisation
161
+ // -----------------------------------------------------------------------
162
+
163
+ private async materialiseOAuth(
164
+ subject: ResolvedOAuthSubject,
165
+ ): Promise<MaterialisationResult> {
166
+ const { connection } = subject;
167
+ const connectionId = connection.id;
168
+
169
+ // 1. Get the stored access token
170
+ const accessToken = await getStoredAccessToken(
171
+ this.backend,
172
+ connectionId,
173
+ );
174
+
175
+ if (!accessToken) {
176
+ return {
177
+ ok: false,
178
+ error: `No access token found for OAuth connection "${connectionId}" ` +
179
+ `(provider="${connection.providerKey}"). The connection is disconnected.`,
180
+ };
181
+ }
182
+
183
+ // 2. Check if the token is expired and needs refresh
184
+ if (
185
+ isTokenExpired(connection.expiresAt) &&
186
+ connection.hasRefreshToken
187
+ ) {
188
+ return this.refreshAndMaterialise(subject, connectionId);
189
+ }
190
+
191
+ // 3. Token is valid — return it
192
+ return {
193
+ ok: true,
194
+ credential: {
195
+ value: accessToken,
196
+ handleType: HandleType.LocalOAuth,
197
+ expiresAt: connection.expiresAt,
198
+ },
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Refresh an expired OAuth token and return the materialised result.
204
+ *
205
+ * Uses the circuit breaker to prevent retry storms and the deduplicator
206
+ * to coalesce concurrent refresh attempts for the same connection.
207
+ */
208
+ private async refreshAndMaterialise(
209
+ subject: ResolvedOAuthSubject,
210
+ connectionId: string,
211
+ ): Promise<MaterialisationResult> {
212
+ // Check circuit breaker
213
+ if (this.circuitBreaker.isOpen(connectionId)) {
214
+ return {
215
+ ok: false,
216
+ error: `Token refresh circuit breaker is open for connection "${connectionId}". ` +
217
+ `Too many consecutive refresh failures. Re-authorization may be required.`,
218
+ };
219
+ }
220
+
221
+ if (!this.tokenRefreshFn) {
222
+ return {
223
+ ok: false,
224
+ error: `Token for OAuth connection "${connectionId}" is expired but no refresh ` +
225
+ `function is configured. Re-authorization required.`,
226
+ };
227
+ }
228
+
229
+ // Get the refresh token
230
+ const refreshToken = await getStoredRefreshToken(
231
+ this.backend,
232
+ connectionId,
233
+ );
234
+ if (!refreshToken) {
235
+ return {
236
+ ok: false,
237
+ error: `Token for OAuth connection "${connectionId}" is expired and no refresh ` +
238
+ `token is available. Re-authorization required.`,
239
+ };
240
+ }
241
+
242
+ try {
243
+ // Use deduplicator to prevent concurrent refresh attempts
244
+ const tokenRefreshFn = this.tokenRefreshFn;
245
+ const backend = this.backend;
246
+ const circuitBreaker = this.circuitBreaker;
247
+
248
+ const newAccessToken = await this.deduplicator.deduplicate(
249
+ connectionId,
250
+ async () => {
251
+ const result = await tokenRefreshFn(connectionId, refreshToken);
252
+ if (!result.success) {
253
+ circuitBreaker.recordFailure(connectionId);
254
+ throw new Error(result.error);
255
+ }
256
+
257
+ circuitBreaker.recordSuccess(connectionId);
258
+
259
+ // Persist the refreshed tokens to the secure-key backend
260
+ // (but NOT to any assistant-visible state)
261
+ const persisted = await persistRefreshedTokens(
262
+ backend,
263
+ connectionId,
264
+ {
265
+ accessToken: result.accessToken,
266
+ expiresIn: result.expiresAt
267
+ ? Math.floor((result.expiresAt - Date.now()) / 1000)
268
+ : null,
269
+ },
270
+ );
271
+
272
+ return persisted.accessToken;
273
+ },
274
+ );
275
+
276
+ return {
277
+ ok: true,
278
+ credential: {
279
+ value: newAccessToken,
280
+ handleType: HandleType.LocalOAuth,
281
+ expiresAt: null, // Refresh result expiry is tracked internally
282
+ },
283
+ };
284
+ } catch (err) {
285
+ const message = err instanceof Error ? err.message : String(err);
286
+ return {
287
+ ok: false,
288
+ error: `Failed to refresh token for OAuth connection "${connectionId}": ${message}`,
289
+ };
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Reset circuit breaker and deduplicator state (primarily for testing).
295
+ */
296
+ reset(): void {
297
+ this.circuitBreaker.clear();
298
+ this.deduplicator.clear();
299
+ }
300
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Managed platform OAuth materializer.
3
+ *
4
+ * Materializes a `platform_oauth` handle into a short-lived access token
5
+ * by calling the platform's CES token-materialization endpoint. The
6
+ * materialized token is returned to the caller for immediate use (e.g.
7
+ * injection into an HTTP request or command environment) but is **never**
8
+ * persisted to any local storage — it exists only in memory for the
9
+ * duration of the execution.
10
+ *
11
+ * Security invariants:
12
+ * - Materialized tokens are never written to disk.
13
+ * - Materialized tokens are never logged (not even partially).
14
+ * - Platform errors are surfaced as structured errors without leaking secrets.
15
+ * - If the platform cannot be reached, materialization fails closed.
16
+ *
17
+ * The materializer expects a resolved `ManagedSubject` from
18
+ * `subjects/managed.ts`. It does not perform handle parsing or catalog
19
+ * lookup — that is the resolver's responsibility.
20
+ */
21
+
22
+ import type { ManagedSubject } from "../subjects/managed.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Materialization result
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Successful materialization result.
30
+ *
31
+ * The `accessToken` field contains the short-lived token obtained from
32
+ * the platform. Callers MUST NOT persist this value — it should be used
33
+ * immediately for request injection and then discarded.
34
+ */
35
+ export interface MaterializedToken {
36
+ /** The short-lived access token. */
37
+ accessToken: string;
38
+ /** Token type (typically "Bearer"). */
39
+ tokenType: string;
40
+ /** Epoch ms when the token expires (null if the platform didn't report expiry). */
41
+ expiresAt: number | null;
42
+ /** Provider key (mirrored from the subject for convenience). */
43
+ provider: string;
44
+ /** Connection ID (mirrored from the subject for convenience). */
45
+ connectionId: string;
46
+ }
47
+
48
+ export type MaterializeResult =
49
+ | { ok: true; token: MaterializedToken }
50
+ | { ok: false; error: MaterializationError };
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Materialization errors
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export class MaterializationError extends Error {
57
+ readonly code: string;
58
+
59
+ constructor(code: string, message: string) {
60
+ super(message);
61
+ this.name = "MaterializationError";
62
+ this.code = code;
63
+ }
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Platform token response shape
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Shape of the platform's CES token-materialization response.
72
+ *
73
+ * The platform issues a short-lived access token for the specified
74
+ * connection. The token is pre-authorized for the scopes granted on
75
+ * the connection.
76
+ */
77
+ interface PlatformTokenResponse {
78
+ access_token: string;
79
+ token_type?: string;
80
+ /** Seconds until the token expires. */
81
+ expires_in?: number | null;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Materializer options
86
+ // ---------------------------------------------------------------------------
87
+
88
+ export interface ManagedMaterializerOptions {
89
+ /**
90
+ * Platform base URL (without trailing slash).
91
+ */
92
+ platformBaseUrl: string;
93
+ /**
94
+ * Assistant API key for authenticating with the platform.
95
+ */
96
+ assistantApiKey: string;
97
+ /**
98
+ * Optional custom fetch implementation (for testing).
99
+ */
100
+ fetch?: typeof globalThis.fetch;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Materializer implementation
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Materialize a managed OAuth subject into a short-lived access token
109
+ * by calling the platform's token-materialization endpoint.
110
+ *
111
+ * The endpoint is:
112
+ * POST {platformBaseUrl}/v1/ces/connections/{connectionId}/materialize
113
+ *
114
+ * The platform validates the assistant API key, checks that the connection
115
+ * is active, and returns a fresh access token (refreshing upstream if
116
+ * needed).
117
+ *
118
+ * Fail-closed: any error results in a structured `MaterializationError`
119
+ * rather than a partial or fallback result.
120
+ */
121
+ export async function materializeManagedToken(
122
+ subject: ManagedSubject,
123
+ options: ManagedMaterializerOptions,
124
+ ): Promise<MaterializeResult> {
125
+ // -- Validate prerequisites -----------------------------------------------
126
+ if (!options.platformBaseUrl) {
127
+ return {
128
+ ok: false,
129
+ error: new MaterializationError(
130
+ "MISSING_PLATFORM_URL",
131
+ "Platform base URL is required for managed token materialization",
132
+ ),
133
+ };
134
+ }
135
+
136
+ if (!options.assistantApiKey) {
137
+ return {
138
+ ok: false,
139
+ error: new MaterializationError(
140
+ "MISSING_API_KEY",
141
+ "Assistant API key is required for managed token materialization",
142
+ ),
143
+ };
144
+ }
145
+
146
+ // -- Call platform token endpoint -----------------------------------------
147
+ const fetchFn = options.fetch ?? globalThis.fetch;
148
+ const materializeUrl =
149
+ `${options.platformBaseUrl}/v1/ces/connections/${subject.connectionId}/materialize`;
150
+
151
+ let response: Response;
152
+ try {
153
+ response = await fetchFn(materializeUrl, {
154
+ method: "POST",
155
+ headers: {
156
+ Authorization: `Api-Key ${options.assistantApiKey}`,
157
+ Accept: "application/json",
158
+ "Content-Type": "application/json",
159
+ },
160
+ body: JSON.stringify({}),
161
+ });
162
+ } catch (err) {
163
+ const message = err instanceof Error ? err.message : String(err);
164
+ return {
165
+ ok: false,
166
+ error: new MaterializationError(
167
+ "PLATFORM_UNREACHABLE",
168
+ `Failed to reach platform token endpoint: ${sanitizeError(message)}`,
169
+ ),
170
+ };
171
+ }
172
+
173
+ // -- Handle error responses -----------------------------------------------
174
+ if (!response.ok) {
175
+ return {
176
+ ok: false,
177
+ error: mapPlatformError(response.status, subject.connectionId),
178
+ };
179
+ }
180
+
181
+ // -- Parse token response -------------------------------------------------
182
+ let body: PlatformTokenResponse;
183
+ try {
184
+ body = (await response.json()) as PlatformTokenResponse;
185
+ } catch {
186
+ return {
187
+ ok: false,
188
+ error: new MaterializationError(
189
+ "INVALID_TOKEN_RESPONSE",
190
+ "Platform token endpoint returned invalid JSON",
191
+ ),
192
+ };
193
+ }
194
+
195
+ if (!body.access_token || typeof body.access_token !== "string") {
196
+ return {
197
+ ok: false,
198
+ error: new MaterializationError(
199
+ "INVALID_TOKEN_RESPONSE",
200
+ "Platform token response missing access_token",
201
+ ),
202
+ };
203
+ }
204
+
205
+ // -- Build materialized token ---------------------------------------------
206
+ const expiresAt = computeExpiresAt(body.expires_in);
207
+
208
+ const token: MaterializedToken = {
209
+ accessToken: body.access_token,
210
+ tokenType: body.token_type ?? "Bearer",
211
+ expiresAt,
212
+ provider: subject.provider,
213
+ connectionId: subject.connectionId,
214
+ };
215
+
216
+ return { ok: true, token };
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Helpers
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Map a platform HTTP error status to a structured MaterializationError.
225
+ */
226
+ function mapPlatformError(
227
+ status: number,
228
+ connectionId: string,
229
+ ): MaterializationError {
230
+ switch (status) {
231
+ case 401:
232
+ return new MaterializationError(
233
+ "PLATFORM_AUTH_FAILED",
234
+ "Assistant API key is invalid or expired (HTTP 401)",
235
+ );
236
+ case 403:
237
+ return new MaterializationError(
238
+ "PLATFORM_FORBIDDEN",
239
+ "Assistant is not authorized to materialize this connection (HTTP 403)",
240
+ );
241
+ case 404:
242
+ return new MaterializationError(
243
+ "CONNECTION_NOT_FOUND",
244
+ `Connection ${connectionId} not found on the platform (HTTP 404)`,
245
+ );
246
+ default:
247
+ return new MaterializationError(
248
+ `PLATFORM_HTTP_${status}`,
249
+ `Platform token endpoint returned HTTP ${status}`,
250
+ );
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Compute the absolute expiry timestamp from an `expires_in` seconds value.
256
+ * Returns null if the value is missing or zero.
257
+ */
258
+ function computeExpiresAt(
259
+ expiresIn: number | null | undefined,
260
+ ): number | null {
261
+ if (expiresIn == null || expiresIn <= 0) return null;
262
+ return Date.now() + expiresIn * 1000;
263
+ }
264
+
265
+ /**
266
+ * Sanitize error messages to avoid leaking secrets.
267
+ */
268
+ function sanitizeError(message: string): string {
269
+ return message.replace(/Api-Key\s+\S+/gi, "Api-Key [REDACTED]");
270
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * CES private data-root layout.
3
+ *
4
+ * Defines the directory structure for CES-private durable state (grants,
5
+ * audit logs, tool store). This state is never stored on the assistant-visible
6
+ * workspace/data root — it lives in a CES-only path that the assistant process
7
+ * cannot read or write.
8
+ *
9
+ * Two modes:
10
+ *
11
+ * - **Local**: CES private state lives under the Vellum root's `protected/`
12
+ * directory at `<rootDir>/protected/credential-executor/`.
13
+ *
14
+ * - **Managed**: CES private state lives at `/ces-data`, a dedicated volume
15
+ * mounted only into the CES container. The assistant container never sees
16
+ * this volume.
17
+ *
18
+ * The assistant-visible data root (where workspace, embeddings, etc. live)
19
+ * is a separate path and must never be used for CES-private writes.
20
+ */
21
+
22
+ import { join } from "node:path";
23
+ import { homedir } from "node:os";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Mode detection
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export type CesMode = "local" | "managed";
30
+
31
+ /**
32
+ * Determine the CES operating mode from the environment.
33
+ *
34
+ * `CES_MODE=managed` is set explicitly in managed container entrypoints.
35
+ * Everything else defaults to local.
36
+ */
37
+ export function getCesMode(): CesMode {
38
+ return process.env["CES_MODE"] === "managed" ? "managed" : "local";
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Root directory helpers
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Resolve the Vellum root directory, respecting `BASE_DATA_DIR` for
47
+ * multi-instance deployments. Mirrors the logic in `assistant/src/util/platform.ts`.
48
+ */
49
+ function getVellumRootDir(): string {
50
+ const baseDataDir = process.env["BASE_DATA_DIR"]?.trim();
51
+ return join(baseDataDir || homedir(), ".vellum");
52
+ }
53
+
54
+ /** Well-known managed CES data root (dedicated volume mount). */
55
+ const MANAGED_CES_DATA_ROOT = "/ces-data";
56
+
57
+ /**
58
+ * Return the CES-private data root.
59
+ *
60
+ * - Local: `<vellumRoot>/protected/credential-executor/`
61
+ * - Managed: `/ces-data`
62
+ */
63
+ export function getCesDataRoot(mode?: CesMode): string {
64
+ const resolvedMode = mode ?? getCesMode();
65
+ if (resolvedMode === "managed") {
66
+ return MANAGED_CES_DATA_ROOT;
67
+ }
68
+ return join(getVellumRootDir(), "protected", "credential-executor");
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Subdirectory layout
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /** Directory for CES grant persistence. */
76
+ export function getCesGrantsDir(mode?: CesMode): string {
77
+ return join(getCesDataRoot(mode), "grants");
78
+ }
79
+
80
+ /** Directory for CES audit log persistence. */
81
+ export function getCesAuditDir(mode?: CesMode): string {
82
+ return join(getCesDataRoot(mode), "audit");
83
+ }
84
+
85
+ /** Directory for CES secure tool store (registered secure command tools). */
86
+ export function getCesToolStoreDir(mode?: CesMode): string {
87
+ return join(getCesDataRoot(mode), "toolstore");
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Bootstrap socket path (managed mode only)
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /** Default directory for the bootstrap Unix socket shared volume. */
95
+ const BOOTSTRAP_SOCKET_DIR = "/run/ces";
96
+
97
+ /** Default bootstrap socket filename. */
98
+ const BOOTSTRAP_SOCKET_NAME = "ces.sock";
99
+
100
+ /**
101
+ * Return the path to the bootstrap Unix socket.
102
+ *
103
+ * In managed mode, CES listens on this socket for exactly one assistant
104
+ * connection, then unlinks it. The path is on a shared `emptyDir` volume
105
+ * visible to both containers.
106
+ *
107
+ * Can be overridden via `CES_BOOTSTRAP_SOCKET` env var.
108
+ */
109
+ export function getBootstrapSocketPath(): string {
110
+ return (
111
+ process.env["CES_BOOTSTRAP_SOCKET"] ??
112
+ join(BOOTSTRAP_SOCKET_DIR, BOOTSTRAP_SOCKET_NAME)
113
+ );
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Health port (managed mode only)
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** Default health probe port for managed CES. */
121
+ const DEFAULT_HEALTH_PORT = 7841;
122
+
123
+ /**
124
+ * Return the health probe port for managed mode.
125
+ *
126
+ * Health probes are served on a dedicated HTTP port, separate from the
127
+ * Unix socket command transport. This ensures liveness/readiness probes
128
+ * work without a Unix socket client.
129
+ */
130
+ export function getHealthPort(): number {
131
+ const envPort = process.env["CES_HEALTH_PORT"];
132
+ if (envPort) {
133
+ const parsed = parseInt(envPort, 10);
134
+ if (!isNaN(parsed) && parsed > 0) return parsed;
135
+ }
136
+ return DEFAULT_HEALTH_PORT;
137
+ }