@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.
- package/Dockerfile +55 -0
- package/bun.lock +37 -0
- package/package.json +32 -0
- package/src/__tests__/command-executor.test.ts +1333 -0
- package/src/__tests__/command-validator.test.ts +708 -0
- package/src/__tests__/command-workspace.test.ts +997 -0
- package/src/__tests__/grant-store.test.ts +467 -0
- package/src/__tests__/http-executor.test.ts +1251 -0
- package/src/__tests__/http-policy.test.ts +970 -0
- package/src/__tests__/local-materializers.test.ts +826 -0
- package/src/__tests__/managed-materializers.test.ts +961 -0
- package/src/__tests__/toolstore.test.ts +539 -0
- package/src/__tests__/transport.test.ts +388 -0
- package/src/audit/store.ts +188 -0
- package/src/commands/auth-adapters.ts +169 -0
- package/src/commands/executor.ts +840 -0
- package/src/commands/output-scan.ts +157 -0
- package/src/commands/profiles.ts +282 -0
- package/src/commands/validator.ts +438 -0
- package/src/commands/workspace.ts +512 -0
- package/src/grants/index.ts +17 -0
- package/src/grants/persistent-store.ts +247 -0
- package/src/grants/rpc-handlers.ts +269 -0
- package/src/grants/temporary-store.ts +219 -0
- package/src/http/audit.ts +84 -0
- package/src/http/executor.ts +540 -0
- package/src/http/path-template.ts +179 -0
- package/src/http/policy.ts +256 -0
- package/src/http/response-filter.ts +233 -0
- package/src/index.ts +106 -0
- package/src/main.ts +263 -0
- package/src/managed-main.ts +420 -0
- package/src/materializers/local.ts +300 -0
- package/src/materializers/managed-platform.ts +270 -0
- package/src/paths.ts +137 -0
- package/src/server.ts +636 -0
- package/src/subjects/local.ts +177 -0
- package/src/subjects/managed.ts +290 -0
- package/src/toolstore/integrity.ts +94 -0
- package/src/toolstore/manifest.ts +154 -0
- package/src/toolstore/publish.ts +342 -0
- 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
|
+
}
|