@vellumai/credential-executor 0.10.7 → 0.10.8-dev.202607102228.5945895
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/node_modules/@vellumai/service-contracts/package.json +1 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
- package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
- package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
- package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
- package/package.json +2 -3
- package/src/__tests__/bulk-set-credentials.test.ts +1 -1
- package/src/__tests__/local-standalone.test.ts +5 -36
- package/src/__tests__/managed-integration.test.ts +112 -91
- package/src/__tests__/managed-reconnect.test.ts +2 -2
- package/src/__tests__/transport.test.ts +23 -27
- package/src/cli.ts +1 -1
- package/src/index.ts +8 -88
- package/src/main.ts +228 -340
- package/src/paths.ts +4 -20
- package/src/server.ts +52 -469
- package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
- package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
- package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
- package/src/__tests__/command-executor.test.ts +0 -1879
- package/src/__tests__/command-validator.test.ts +0 -1405
- package/src/__tests__/command-workspace.test.ts +0 -1050
- package/src/__tests__/grant-store.test.ts +0 -689
- package/src/__tests__/http-executor.test.ts +0 -1336
- package/src/__tests__/http-policy.test.ts +0 -1069
- package/src/__tests__/local-materializers.test.ts +0 -860
- package/src/__tests__/local-token-refresh.test.ts +0 -361
- package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
- package/src/__tests__/managed-lazy-getters.test.ts +0 -359
- package/src/__tests__/managed-materializers.test.ts +0 -1028
- package/src/__tests__/managed-rejection.test.ts +0 -43
- package/src/__tests__/toolstore.test.ts +0 -773
- package/src/audit/store.ts +0 -188
- package/src/commands/auth-adapters.ts +0 -169
- package/src/commands/egress-hooks.ts +0 -203
- package/src/commands/executor.ts +0 -1155
- package/src/commands/output-scan.ts +0 -157
- package/src/commands/profiles.ts +0 -286
- package/src/commands/validator.ts +0 -702
- package/src/commands/workspace.ts +0 -550
- package/src/grants/index.ts +0 -17
- package/src/grants/persistent-store.ts +0 -309
- package/src/grants/rpc-handlers.ts +0 -293
- package/src/grants/temporary-store.ts +0 -289
- package/src/http/audit.ts +0 -84
- package/src/http/executor.ts +0 -684
- package/src/http/path-template.ts +0 -245
- package/src/http/policy.ts +0 -238
- package/src/http/response-filter.ts +0 -233
- package/src/managed-errors.ts +0 -9
- package/src/managed-lazy-getters.ts +0 -106
- package/src/managed-main.ts +0 -822
- package/src/materializers/local-oauth-lookup.ts +0 -98
- package/src/materializers/local-token-refresh.ts +0 -287
- package/src/materializers/local.ts +0 -316
- package/src/materializers/managed-platform.ts +0 -295
- package/src/subjects/local.ts +0 -177
- package/src/subjects/managed.ts +0 -311
- package/src/subjects/policy.ts +0 -79
- package/src/toolstore/integrity.ts +0 -94
- package/src/toolstore/manifest.ts +0 -154
- package/src/toolstore/publish.ts +0 -571
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CES-native read-only OAuth connection lookup for local mode.
|
|
3
|
-
*
|
|
4
|
-
* In local mode, CES runs as a child process of the assistant on the same
|
|
5
|
-
* machine and can read the assistant's SQLite database to look up OAuth
|
|
6
|
-
* connections.
|
|
7
|
-
*
|
|
8
|
-
* This implementation opens the database in read-only mode and queries the
|
|
9
|
-
* `oauth_connections` table directly using raw SQLite queries. It does not
|
|
10
|
-
* use Drizzle ORM to avoid importing assistant-internal schema modules.
|
|
11
|
-
*
|
|
12
|
-
* The lookup is read-only — CES never modifies OAuth connection records.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import Database from "bun:sqlite";
|
|
16
|
-
import { existsSync } from "node:fs";
|
|
17
|
-
import { join } from "node:path";
|
|
18
|
-
|
|
19
|
-
import type { OAuthConnectionRecord } from "@vellumai/credential-storage";
|
|
20
|
-
import { oauthConnectionAccessTokenPath } from "@vellumai/credential-storage";
|
|
21
|
-
import type { OAuthConnectionLookup } from "../subjects/local.js";
|
|
22
|
-
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// Raw SQLite row shape (matches oauth_connections table)
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
interface OAuthConnectionRow {
|
|
28
|
-
id: string;
|
|
29
|
-
oauth_app_id: string;
|
|
30
|
-
provider_key: string;
|
|
31
|
-
account_info: string | null;
|
|
32
|
-
granted_scopes: string;
|
|
33
|
-
expires_at: number | null;
|
|
34
|
-
has_refresh_token: number;
|
|
35
|
-
status: string;
|
|
36
|
-
label: string | null;
|
|
37
|
-
metadata: string | null;
|
|
38
|
-
created_at: number;
|
|
39
|
-
updated_at: number;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// Row → OAuthConnectionRecord mapping
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
function rowToRecord(row: OAuthConnectionRow): OAuthConnectionRecord {
|
|
47
|
-
return {
|
|
48
|
-
id: row.id,
|
|
49
|
-
providerKey: row.provider_key,
|
|
50
|
-
accountInfo: row.account_info,
|
|
51
|
-
grantedScopes: JSON.parse(row.granted_scopes || "[]"),
|
|
52
|
-
accessTokenPath: oauthConnectionAccessTokenPath(row.id),
|
|
53
|
-
hasRefreshToken: row.has_refresh_token === 1,
|
|
54
|
-
expiresAt: row.expires_at,
|
|
55
|
-
createdAt: row.created_at,
|
|
56
|
-
updatedAt: row.updated_at,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
// Lookup implementation
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Create a read-only OAuth connection lookup backed by the assistant's
|
|
66
|
-
* SQLite database.
|
|
67
|
-
*
|
|
68
|
-
* @param workspaceDir - The workspace directory (e.g. `$VELLUM_WORKSPACE_DIR`).
|
|
69
|
-
*/
|
|
70
|
-
export function createLocalOAuthLookup(
|
|
71
|
-
workspaceDir: string,
|
|
72
|
-
): OAuthConnectionLookup {
|
|
73
|
-
const dbPath = join(workspaceDir, "data", "db", "assistant.db");
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
getById(connectionId: string): OAuthConnectionRecord | undefined {
|
|
77
|
-
if (!existsSync(dbPath)) return undefined;
|
|
78
|
-
|
|
79
|
-
let db: Database | undefined;
|
|
80
|
-
try {
|
|
81
|
-
db = new Database(dbPath, { readonly: true });
|
|
82
|
-
const row = db
|
|
83
|
-
.query<
|
|
84
|
-
OAuthConnectionRow,
|
|
85
|
-
[string, string]
|
|
86
|
-
>(`SELECT * FROM oauth_connections WHERE id = ? AND status = ? LIMIT 1`)
|
|
87
|
-
.get(connectionId, "active");
|
|
88
|
-
|
|
89
|
-
if (!row) return undefined;
|
|
90
|
-
return rowToRecord(row);
|
|
91
|
-
} catch {
|
|
92
|
-
return undefined;
|
|
93
|
-
} finally {
|
|
94
|
-
db?.close();
|
|
95
|
-
}
|
|
96
|
-
},
|
|
97
|
-
};
|
|
98
|
-
}
|
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OAuth token refresh implementation for CES local mode.
|
|
3
|
-
*
|
|
4
|
-
* Performs the actual OAuth2 token refresh by:
|
|
5
|
-
* 1. Looking up the connection's provider and app configuration from the
|
|
6
|
-
* assistant's SQLite database (read-only).
|
|
7
|
-
* 2. Retrieving the client secret from the secure-key backend.
|
|
8
|
-
* 3. Calling the provider's token endpoint with the refresh token.
|
|
9
|
-
* 4. Returning a `TokenRefreshResult` for the `LocalMaterialiser` to persist.
|
|
10
|
-
*
|
|
11
|
-
* This module does NOT import any assistant-internal modules. It queries
|
|
12
|
-
* the SQLite database directly (like `local-oauth-lookup.ts`) and performs
|
|
13
|
-
* the HTTP refresh call inline (replicating the logic from the assistant's
|
|
14
|
-
* `security/oauth2.ts:refreshOAuth2Token`).
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import Database from "bun:sqlite";
|
|
18
|
-
import { existsSync } from "node:fs";
|
|
19
|
-
import { join } from "node:path";
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
computeExpiresAt,
|
|
23
|
-
type SecureKeyBackend,
|
|
24
|
-
type TokenRefreshResult,
|
|
25
|
-
} from "@vellumai/credential-storage";
|
|
26
|
-
|
|
27
|
-
import type { TokenRefreshFn } from "./local.js";
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// SQLite row shapes (match assistant schema without importing Drizzle)
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
interface OAuthConnectionRow {
|
|
34
|
-
id: string;
|
|
35
|
-
oauth_app_id: string;
|
|
36
|
-
provider_key: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
interface OAuthAppRow {
|
|
40
|
-
id: string;
|
|
41
|
-
provider_key: string;
|
|
42
|
-
client_id: string;
|
|
43
|
-
client_secret_credential_path: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface OAuthProviderRow {
|
|
47
|
-
provider_key: string;
|
|
48
|
-
token_url: string;
|
|
49
|
-
refresh_url: string | null;
|
|
50
|
-
token_endpoint_auth_method: string | null;
|
|
51
|
-
token_exchange_body_format: string | null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
// Token endpoint auth method (matches assistant/src/security/oauth2.ts)
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
|
|
58
|
-
type TokenEndpointAuthMethod = "client_secret_basic" | "client_secret_post";
|
|
59
|
-
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
// Refresh config resolution
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
interface RefreshConfig {
|
|
65
|
-
tokenUrl: string;
|
|
66
|
-
clientId: string;
|
|
67
|
-
clientSecret?: string;
|
|
68
|
-
authMethod: TokenEndpointAuthMethod;
|
|
69
|
-
bodyFormat: "form" | "json";
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Resolve the OAuth refresh configuration for a connection by querying
|
|
74
|
-
* the assistant's SQLite database (read-only) and the secure-key backend.
|
|
75
|
-
*/
|
|
76
|
-
async function resolveRefreshConfig(
|
|
77
|
-
dbPath: string,
|
|
78
|
-
connectionId: string,
|
|
79
|
-
secureKeyBackend: SecureKeyBackend,
|
|
80
|
-
): Promise<RefreshConfig | { error: string }> {
|
|
81
|
-
if (!existsSync(dbPath)) {
|
|
82
|
-
return { error: `Database not found at ${dbPath}` };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
let db: Database | undefined;
|
|
86
|
-
try {
|
|
87
|
-
db = new Database(dbPath, { readonly: true });
|
|
88
|
-
|
|
89
|
-
// 1. Look up the connection to get oauth_app_id and provider_key
|
|
90
|
-
const conn = db
|
|
91
|
-
.query<
|
|
92
|
-
OAuthConnectionRow,
|
|
93
|
-
[string, string]
|
|
94
|
-
>(`SELECT id, oauth_app_id, provider_key FROM oauth_connections WHERE id = ? AND status = ? LIMIT 1`)
|
|
95
|
-
.get(connectionId, "active");
|
|
96
|
-
|
|
97
|
-
if (!conn) {
|
|
98
|
-
return {
|
|
99
|
-
error: `No active OAuth connection found for "${connectionId}"`,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// 2. Look up the app to get client_id and client_secret_credential_path
|
|
104
|
-
const app = db
|
|
105
|
-
.query<
|
|
106
|
-
OAuthAppRow,
|
|
107
|
-
[string]
|
|
108
|
-
>(`SELECT id, provider_key, client_id, client_secret_credential_path FROM oauth_apps WHERE id = ? LIMIT 1`)
|
|
109
|
-
.get(conn.oauth_app_id);
|
|
110
|
-
|
|
111
|
-
if (!app) {
|
|
112
|
-
return { error: `No OAuth app found for connection "${connectionId}"` };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// 3. Look up the provider to get token_url and auth method
|
|
116
|
-
const provider = db
|
|
117
|
-
.query<
|
|
118
|
-
OAuthProviderRow,
|
|
119
|
-
[string]
|
|
120
|
-
>(`SELECT provider_key, token_url, refresh_url, token_endpoint_auth_method, token_exchange_body_format FROM oauth_providers WHERE provider_key = ? LIMIT 1`)
|
|
121
|
-
.get(conn.provider_key);
|
|
122
|
-
|
|
123
|
-
if (!provider) {
|
|
124
|
-
return { error: `No OAuth provider found for "${conn.provider_key}"` };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Resolve the effective token URL: prefer refresh_url, fall back to token_url
|
|
128
|
-
const tokenUrl = provider.refresh_url || provider.token_url;
|
|
129
|
-
|
|
130
|
-
if (!tokenUrl || !app.client_id) {
|
|
131
|
-
return {
|
|
132
|
-
error: `Missing OAuth2 refresh config for "${conn.provider_key}"`,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// 4. Retrieve the client secret from secure storage
|
|
137
|
-
const clientSecret = await secureKeyBackend.get(
|
|
138
|
-
app.client_secret_credential_path,
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
const authMethod =
|
|
142
|
-
(provider.token_endpoint_auth_method as TokenEndpointAuthMethod | null) ??
|
|
143
|
-
"client_secret_post";
|
|
144
|
-
const bodyFormat =
|
|
145
|
-
(provider.token_exchange_body_format as "form" | "json" | null) ?? "form";
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
tokenUrl,
|
|
149
|
-
clientId: app.client_id,
|
|
150
|
-
clientSecret,
|
|
151
|
-
authMethod,
|
|
152
|
-
bodyFormat,
|
|
153
|
-
};
|
|
154
|
-
} catch (err) {
|
|
155
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
156
|
-
return { error: `Failed to resolve refresh config: ${msg}` };
|
|
157
|
-
} finally {
|
|
158
|
-
db?.close();
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// ---------------------------------------------------------------------------
|
|
163
|
-
// HTTP token refresh (replicates assistant/src/security/oauth2.ts logic)
|
|
164
|
-
// ---------------------------------------------------------------------------
|
|
165
|
-
|
|
166
|
-
interface RefreshTokenResponse {
|
|
167
|
-
accessToken: string;
|
|
168
|
-
refreshToken?: string;
|
|
169
|
-
expiresIn?: number;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async function performTokenRefresh(
|
|
173
|
-
config: RefreshConfig,
|
|
174
|
-
refreshToken: string,
|
|
175
|
-
): Promise<RefreshTokenResponse> {
|
|
176
|
-
const body: Record<string, string> = {
|
|
177
|
-
grant_type: "refresh_token",
|
|
178
|
-
refresh_token: refreshToken,
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
const headers: Record<string, string> = {
|
|
182
|
-
"Content-Type":
|
|
183
|
-
config.bodyFormat === "json"
|
|
184
|
-
? "application/json"
|
|
185
|
-
: "application/x-www-form-urlencoded",
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
if (config.clientSecret && config.authMethod === "client_secret_basic") {
|
|
189
|
-
const credentials = Buffer.from(
|
|
190
|
-
`${config.clientId}:${config.clientSecret}`,
|
|
191
|
-
).toString("base64");
|
|
192
|
-
headers["Authorization"] = `Basic ${credentials}`;
|
|
193
|
-
} else {
|
|
194
|
-
body.client_id = config.clientId;
|
|
195
|
-
if (config.clientSecret) {
|
|
196
|
-
body.client_secret = config.clientSecret;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const resp = await fetch(config.tokenUrl, {
|
|
201
|
-
method: "POST",
|
|
202
|
-
headers,
|
|
203
|
-
body:
|
|
204
|
-
config.bodyFormat === "json"
|
|
205
|
-
? JSON.stringify(body)
|
|
206
|
-
: new URLSearchParams(body),
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
if (!resp.ok) {
|
|
210
|
-
const rawBody = await resp.text().catch(() => "");
|
|
211
|
-
let errorCode = "";
|
|
212
|
-
try {
|
|
213
|
-
const parsed = JSON.parse(rawBody) as Record<string, unknown>;
|
|
214
|
-
if (parsed.error) {
|
|
215
|
-
errorCode = String(parsed.error);
|
|
216
|
-
}
|
|
217
|
-
} catch {
|
|
218
|
-
// non-JSON response
|
|
219
|
-
}
|
|
220
|
-
const detail = errorCode
|
|
221
|
-
? `HTTP ${resp.status}: ${errorCode}`
|
|
222
|
-
: `HTTP ${resp.status}`;
|
|
223
|
-
throw new Error(`OAuth2 token refresh failed (${detail})`);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const data = (await resp.json()) as Record<string, unknown>;
|
|
227
|
-
|
|
228
|
-
return {
|
|
229
|
-
accessToken: data.access_token as string,
|
|
230
|
-
refreshToken: (data.refresh_token as string | undefined) ?? refreshToken,
|
|
231
|
-
expiresIn: data.expires_in as number | undefined,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// ---------------------------------------------------------------------------
|
|
236
|
-
// Public factory
|
|
237
|
-
// ---------------------------------------------------------------------------
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Create a `TokenRefreshFn` for CES local mode.
|
|
241
|
-
*
|
|
242
|
-
* The returned function looks up OAuth configuration from the assistant's
|
|
243
|
-
* SQLite database (read-only) and performs the HTTP token refresh call.
|
|
244
|
-
* Token persistence is handled by the `LocalMaterialiser` after this
|
|
245
|
-
* function returns.
|
|
246
|
-
*
|
|
247
|
-
* @param vellumRoot - The Vellum root directory (e.g. `~/.vellum`).
|
|
248
|
-
* @param secureKeyBackend - Backend for retrieving the OAuth client secret.
|
|
249
|
-
*/
|
|
250
|
-
export function createLocalTokenRefreshFn(
|
|
251
|
-
workspaceDir: string,
|
|
252
|
-
secureKeyBackend: SecureKeyBackend,
|
|
253
|
-
): TokenRefreshFn {
|
|
254
|
-
const dbPath = join(workspaceDir, "data", "db", "assistant.db");
|
|
255
|
-
|
|
256
|
-
return async (
|
|
257
|
-
connectionId: string,
|
|
258
|
-
refreshToken: string,
|
|
259
|
-
): Promise<TokenRefreshResult> => {
|
|
260
|
-
// 1. Resolve the refresh config from SQLite + secure storage
|
|
261
|
-
const config = await resolveRefreshConfig(
|
|
262
|
-
dbPath,
|
|
263
|
-
connectionId,
|
|
264
|
-
secureKeyBackend,
|
|
265
|
-
);
|
|
266
|
-
|
|
267
|
-
if ("error" in config) {
|
|
268
|
-
return { success: false, error: config.error };
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// 2. Perform the HTTP token refresh
|
|
272
|
-
try {
|
|
273
|
-
const result = await performTokenRefresh(config, refreshToken);
|
|
274
|
-
const expiresAt = computeExpiresAt(result.expiresIn ?? null);
|
|
275
|
-
|
|
276
|
-
return {
|
|
277
|
-
success: true,
|
|
278
|
-
accessToken: result.accessToken,
|
|
279
|
-
expiresAt,
|
|
280
|
-
refreshToken: result.refreshToken,
|
|
281
|
-
};
|
|
282
|
-
} catch (err) {
|
|
283
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
284
|
-
return { success: false, error: message };
|
|
285
|
-
}
|
|
286
|
-
};
|
|
287
|
-
}
|
|
@@ -1,316 +0,0 @@
|
|
|
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 InjectionTemplate,
|
|
26
|
-
type SecureKeyBackend,
|
|
27
|
-
type TokenRefreshResult,
|
|
28
|
-
getStoredAccessToken,
|
|
29
|
-
getStoredRefreshToken,
|
|
30
|
-
isTokenExpired,
|
|
31
|
-
RefreshCircuitBreaker,
|
|
32
|
-
RefreshDeduplicator,
|
|
33
|
-
persistRefreshedTokens,
|
|
34
|
-
} from "@vellumai/credential-storage";
|
|
35
|
-
import { HandleType } from "@vellumai/service-contracts/credential-rpc";
|
|
36
|
-
|
|
37
|
-
import type {
|
|
38
|
-
ResolvedLocalSubject,
|
|
39
|
-
ResolvedOAuthSubject,
|
|
40
|
-
ResolvedStaticSubject,
|
|
41
|
-
} from "../subjects/local.js";
|
|
42
|
-
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
// Materialisation result
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* A materialised credential value ready for injection into an execution
|
|
49
|
-
* environment. The value is ephemeral and must not be persisted to any
|
|
50
|
-
* assistant-visible store.
|
|
51
|
-
*/
|
|
52
|
-
export interface MaterialisedCredential {
|
|
53
|
-
/** The credential value (secret, token, etc.). */
|
|
54
|
-
value: string;
|
|
55
|
-
/** The handle type that produced this value. */
|
|
56
|
-
handleType: HandleType;
|
|
57
|
-
/** For OAuth: the token expiry timestamp (null if unknown). */
|
|
58
|
-
expiresAt?: number | null;
|
|
59
|
-
/** Injection templates from the credential metadata (local_static only). */
|
|
60
|
-
injectionTemplates?: InjectionTemplate[];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export type MaterialisationResult =
|
|
64
|
-
| { ok: true; credential: MaterialisedCredential }
|
|
65
|
-
| { ok: false; error: string };
|
|
66
|
-
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
// Token refresh callback
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Callback for performing the actual OAuth token refresh network call.
|
|
73
|
-
*
|
|
74
|
-
* CES delegates the refresh network call to callers so it remains
|
|
75
|
-
* transport-agnostic. The callback receives the connection ID and
|
|
76
|
-
* refresh token, and returns a `TokenRefreshResult` from the shared
|
|
77
|
-
* credential-storage primitives.
|
|
78
|
-
*/
|
|
79
|
-
export type TokenRefreshFn = (
|
|
80
|
-
connectionId: string,
|
|
81
|
-
refreshToken: string,
|
|
82
|
-
) => Promise<TokenRefreshResult>;
|
|
83
|
-
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
// Local materialiser
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
|
-
|
|
88
|
-
export interface LocalMaterialiserDeps {
|
|
89
|
-
/** Secure-key backend for retrieving secret values. */
|
|
90
|
-
secureKeyBackend: SecureKeyBackend;
|
|
91
|
-
/** Optional token refresh callback for OAuth tokens. */
|
|
92
|
-
tokenRefreshFn?: TokenRefreshFn;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Local credential materialiser.
|
|
97
|
-
*
|
|
98
|
-
* Stateful: maintains a per-connection circuit breaker and refresh
|
|
99
|
-
* deduplicator for OAuth token refresh. Create one instance per CES
|
|
100
|
-
* process lifetime.
|
|
101
|
-
*/
|
|
102
|
-
export class LocalMaterialiser {
|
|
103
|
-
private readonly backend: SecureKeyBackend;
|
|
104
|
-
private readonly tokenRefreshFn?: TokenRefreshFn;
|
|
105
|
-
private readonly circuitBreaker = new RefreshCircuitBreaker();
|
|
106
|
-
private readonly deduplicator = new RefreshDeduplicator();
|
|
107
|
-
|
|
108
|
-
constructor(deps: LocalMaterialiserDeps) {
|
|
109
|
-
this.backend = deps.secureKeyBackend;
|
|
110
|
-
this.tokenRefreshFn = deps.tokenRefreshFn;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Materialise a resolved local subject into a credential value.
|
|
115
|
-
*
|
|
116
|
-
* Dispatches to the appropriate handler based on the subject type.
|
|
117
|
-
* Returns a discriminated result — never throws for expected failure
|
|
118
|
-
* modes (missing keys, disconnected connections, expired tokens).
|
|
119
|
-
*/
|
|
120
|
-
async materialise(
|
|
121
|
-
subject: ResolvedLocalSubject,
|
|
122
|
-
): Promise<MaterialisationResult> {
|
|
123
|
-
switch (subject.type) {
|
|
124
|
-
case HandleType.LocalStatic:
|
|
125
|
-
return this.materialiseStatic(subject);
|
|
126
|
-
case HandleType.LocalOAuth:
|
|
127
|
-
return this.materialiseOAuth(subject);
|
|
128
|
-
default:
|
|
129
|
-
return {
|
|
130
|
-
ok: false,
|
|
131
|
-
error: `Unsupported subject type for local materialisation`,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// -----------------------------------------------------------------------
|
|
137
|
-
// Static secret materialisation
|
|
138
|
-
// -----------------------------------------------------------------------
|
|
139
|
-
|
|
140
|
-
private async materialiseStatic(
|
|
141
|
-
subject: ResolvedStaticSubject,
|
|
142
|
-
): Promise<MaterialisationResult> {
|
|
143
|
-
const secretValue = await this.backend.get(subject.storageKey);
|
|
144
|
-
if (secretValue === undefined) {
|
|
145
|
-
return {
|
|
146
|
-
ok: false,
|
|
147
|
-
error: `Secure key "${subject.storageKey}" not found in local credential store. ` +
|
|
148
|
-
`The credential for service="${subject.metadata.service}", field="${subject.metadata.field}" ` +
|
|
149
|
-
`has metadata but no secret value stored.`,
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return {
|
|
154
|
-
ok: true,
|
|
155
|
-
credential: {
|
|
156
|
-
value: secretValue,
|
|
157
|
-
handleType: HandleType.LocalStatic,
|
|
158
|
-
injectionTemplates: subject.metadata.injectionTemplates,
|
|
159
|
-
},
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// -----------------------------------------------------------------------
|
|
164
|
-
// OAuth token materialisation
|
|
165
|
-
// -----------------------------------------------------------------------
|
|
166
|
-
|
|
167
|
-
private async materialiseOAuth(
|
|
168
|
-
subject: ResolvedOAuthSubject,
|
|
169
|
-
): Promise<MaterialisationResult> {
|
|
170
|
-
const { connection } = subject;
|
|
171
|
-
const connectionId = connection.id;
|
|
172
|
-
|
|
173
|
-
// 1. Get the stored access token
|
|
174
|
-
const accessToken = await getStoredAccessToken(
|
|
175
|
-
this.backend,
|
|
176
|
-
connectionId,
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
if (!accessToken) {
|
|
180
|
-
return {
|
|
181
|
-
ok: false,
|
|
182
|
-
error: `No access token found for OAuth connection "${connectionId}" ` +
|
|
183
|
-
`(provider="${connection.providerKey}"). The connection is disconnected.`,
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// 2. Check if the token is expired and needs refresh
|
|
188
|
-
if (connection.hasRefreshToken) {
|
|
189
|
-
// For refreshable tokens, use the proactive buffer so we can refresh
|
|
190
|
-
// before the token actually expires.
|
|
191
|
-
if (isTokenExpired(connection.expiresAt)) {
|
|
192
|
-
return this.refreshAndMaterialise(subject, connectionId);
|
|
193
|
-
}
|
|
194
|
-
} else {
|
|
195
|
-
// For non-refreshable tokens, check against the hard expiry — use
|
|
196
|
-
// every valid second rather than the 5-minute proactive buffer.
|
|
197
|
-
if (connection.expiresAt && Date.now() >= connection.expiresAt) {
|
|
198
|
-
return {
|
|
199
|
-
ok: false,
|
|
200
|
-
error: `Token for OAuth connection "${connectionId}" is expired and no refresh ` +
|
|
201
|
-
`token is available. Re-authorization required.`,
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// 3. Token is valid — return it
|
|
207
|
-
return {
|
|
208
|
-
ok: true,
|
|
209
|
-
credential: {
|
|
210
|
-
value: accessToken,
|
|
211
|
-
handleType: HandleType.LocalOAuth,
|
|
212
|
-
expiresAt: connection.expiresAt,
|
|
213
|
-
},
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Refresh an expired OAuth token and return the materialised result.
|
|
219
|
-
*
|
|
220
|
-
* Uses the circuit breaker to prevent retry storms and the deduplicator
|
|
221
|
-
* to coalesce concurrent refresh attempts for the same connection.
|
|
222
|
-
*/
|
|
223
|
-
private async refreshAndMaterialise(
|
|
224
|
-
subject: ResolvedOAuthSubject,
|
|
225
|
-
connectionId: string,
|
|
226
|
-
): Promise<MaterialisationResult> {
|
|
227
|
-
// Check circuit breaker
|
|
228
|
-
if (this.circuitBreaker.isOpen(connectionId)) {
|
|
229
|
-
return {
|
|
230
|
-
ok: false,
|
|
231
|
-
error: `Token refresh circuit breaker is open for connection "${connectionId}". ` +
|
|
232
|
-
`Too many consecutive refresh failures. Re-authorization may be required.`,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (!this.tokenRefreshFn) {
|
|
237
|
-
return {
|
|
238
|
-
ok: false,
|
|
239
|
-
error: `Token for OAuth connection "${connectionId}" is expired but no refresh ` +
|
|
240
|
-
`function is configured. Re-authorization required.`,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Get the refresh token
|
|
245
|
-
const refreshToken = await getStoredRefreshToken(
|
|
246
|
-
this.backend,
|
|
247
|
-
connectionId,
|
|
248
|
-
);
|
|
249
|
-
if (!refreshToken) {
|
|
250
|
-
return {
|
|
251
|
-
ok: false,
|
|
252
|
-
error: `Token for OAuth connection "${connectionId}" is expired and no refresh ` +
|
|
253
|
-
`token is available. Re-authorization required.`,
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
try {
|
|
258
|
-
// Use deduplicator to prevent concurrent refresh attempts
|
|
259
|
-
const tokenRefreshFn = this.tokenRefreshFn;
|
|
260
|
-
const backend = this.backend;
|
|
261
|
-
const circuitBreaker = this.circuitBreaker;
|
|
262
|
-
|
|
263
|
-
const newAccessToken = await this.deduplicator.deduplicate(
|
|
264
|
-
connectionId,
|
|
265
|
-
async () => {
|
|
266
|
-
const result = await tokenRefreshFn(connectionId, refreshToken);
|
|
267
|
-
if (!result.success) {
|
|
268
|
-
circuitBreaker.recordFailure(connectionId);
|
|
269
|
-
throw new Error(result.error);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
circuitBreaker.recordSuccess(connectionId);
|
|
273
|
-
|
|
274
|
-
// Persist the refreshed tokens to the secure-key backend
|
|
275
|
-
// (but NOT to any assistant-visible state)
|
|
276
|
-
const persisted = await persistRefreshedTokens(
|
|
277
|
-
backend,
|
|
278
|
-
connectionId,
|
|
279
|
-
{
|
|
280
|
-
accessToken: result.accessToken,
|
|
281
|
-
refreshToken: result.refreshToken,
|
|
282
|
-
expiresIn: result.expiresAt
|
|
283
|
-
? Math.floor((result.expiresAt - Date.now()) / 1000)
|
|
284
|
-
: null,
|
|
285
|
-
},
|
|
286
|
-
);
|
|
287
|
-
|
|
288
|
-
return persisted.accessToken;
|
|
289
|
-
},
|
|
290
|
-
);
|
|
291
|
-
|
|
292
|
-
return {
|
|
293
|
-
ok: true,
|
|
294
|
-
credential: {
|
|
295
|
-
value: newAccessToken,
|
|
296
|
-
handleType: HandleType.LocalOAuth,
|
|
297
|
-
expiresAt: null, // Refresh result expiry is tracked internally
|
|
298
|
-
},
|
|
299
|
-
};
|
|
300
|
-
} catch (err) {
|
|
301
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
302
|
-
return {
|
|
303
|
-
ok: false,
|
|
304
|
-
error: `Failed to refresh token for OAuth connection "${connectionId}": ${message}`,
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Reset circuit breaker and deduplicator state (primarily for testing).
|
|
311
|
-
*/
|
|
312
|
-
reset(): void {
|
|
313
|
-
this.circuitBreaker.clear();
|
|
314
|
-
this.deduplicator.clear();
|
|
315
|
-
}
|
|
316
|
-
}
|