@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.
Files changed (61) hide show
  1. package/Dockerfile +1 -1
  2. package/bun.lock +15 -7
  3. package/node_modules/@vellumai/credential-storage/src/__tests__/package-boundary.test.ts +32 -6
  4. package/node_modules/@vellumai/egress-proxy/src/__tests__/package-boundary.test.ts +32 -1
  5. package/node_modules/@vellumai/{ces-contracts → service-contracts}/bun.lock +1 -1
  6. package/node_modules/@vellumai/{ces-contracts → service-contracts}/package.json +4 -2
  7. package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/__tests__/contracts.test.ts +5 -1
  8. package/node_modules/@vellumai/service-contracts/src/__tests__/package-boundary.test.ts +155 -0
  9. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +23 -0
  10. package/node_modules/@vellumai/service-contracts/src/index.ts +25 -0
  11. package/node_modules/@vellumai/{ces-contracts/src/index.ts → service-contracts/src/transport.ts} +6 -28
  12. package/node_modules/@vellumai/service-contracts/src/trust-rules.ts +116 -0
  13. package/package.json +5 -4
  14. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  15. package/src/__tests__/ces-migrations-002-api-keys.test.ts +185 -0
  16. package/src/__tests__/ces-migrations-runner.test.ts +227 -0
  17. package/src/__tests__/cli.test.ts +139 -0
  18. package/src/__tests__/command-executor.test.ts +71 -42
  19. package/src/__tests__/http-executor.test.ts +1 -1
  20. package/src/__tests__/local-materializers.test.ts +1 -1
  21. package/src/__tests__/local-token-refresh.test.ts +65 -38
  22. package/src/__tests__/managed-integration.test.ts +1 -1
  23. package/src/__tests__/managed-lazy-getters.test.ts +1 -1
  24. package/src/__tests__/managed-materializers.test.ts +1 -1
  25. package/src/__tests__/managed-rejection.test.ts +1 -1
  26. package/src/__tests__/toolstore.test.ts +65 -20
  27. package/src/__tests__/transport.test.ts +13 -4
  28. package/src/audit/store.ts +2 -2
  29. package/src/cli.ts +158 -0
  30. package/src/commands/executor.ts +2 -2
  31. package/src/grants/rpc-handlers.ts +1 -1
  32. package/src/http/__tests__/credential-routes-normalization.test.ts +202 -0
  33. package/src/http/audit.ts +1 -1
  34. package/src/http/credential-routes.ts +53 -7
  35. package/src/http/executor.ts +2 -2
  36. package/src/http/policy.ts +1 -1
  37. package/src/main.ts +120 -50
  38. package/src/managed-errors.ts +2 -2
  39. package/src/managed-lazy-getters.ts +4 -4
  40. package/src/managed-main.ts +9 -3
  41. package/src/materializers/local-oauth-lookup.ts +7 -6
  42. package/src/materializers/local-token-refresh.ts +25 -15
  43. package/src/materializers/local.ts +1 -1
  44. package/src/migrations/001-no-op.ts +19 -0
  45. package/src/migrations/002-api-keys-to-credentials.ts +60 -0
  46. package/src/migrations/registry.ts +15 -0
  47. package/src/migrations/runner.ts +146 -0
  48. package/src/migrations/types.ts +54 -0
  49. package/src/paths.ts +15 -11
  50. package/src/server.ts +2 -2
  51. package/src/subjects/local.ts +2 -2
  52. package/src/subjects/managed.ts +1 -1
  53. package/node_modules/@vellumai/ces-contracts/src/__tests__/trust-rules.test.ts +0 -471
  54. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +0 -436
  55. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/__tests__/grants.test.ts +0 -0
  56. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/error.ts +0 -0
  57. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/grants.ts +0 -0
  58. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/handles.ts +0 -0
  59. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/rendering.ts +0 -0
  60. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/src/rpc.ts +0 -0
  61. /package/node_modules/@vellumai/{ces-contracts → service-contracts}/tsconfig.json +0 -0
@@ -21,6 +21,49 @@ import { timingSafeEqual } from "node:crypto";
21
21
 
22
22
  import type { SecureKeyBackend } from "@vellumai/credential-storage";
23
23
 
24
+ // ---------------------------------------------------------------------------
25
+ // Account key normalization
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Known internal key prefixes. Keys in the encrypted store use slash-separated
30
+ * paths (e.g. `credential/vellum/platform_organization_id`), but callers
31
+ * (especially manual `curl` invocations) often use the colon-separated format
32
+ * visible in the CLI (e.g. `vellum:platform_organization_id`).
33
+ *
34
+ * This normalizer transparently converts colon-separated credential names
35
+ * to the internal format so writes land under the correct key. Without this,
36
+ * a credential stored as `vellum:platform_organization_id` would silently
37
+ * succeed but be invisible to the gateway and assistant, which look up
38
+ * `credential/vellum/platform_organization_id`.
39
+ */
40
+ const CREDENTIAL_PREFIX = "credential/";
41
+
42
+ function normalizeAccountKey(account: string): string {
43
+ // Already in internal format — pass through
44
+ if (account.startsWith(CREDENTIAL_PREFIX)) {
45
+ return account;
46
+ }
47
+
48
+ // Other known internal prefixes — pass through as-is
49
+ if (account.startsWith("oauth/")) {
50
+ return account;
51
+ }
52
+
53
+ // Convert "service:field" → "credential/service/field"
54
+ // Use lastIndexOf to match the canonical split in secret-routes.ts
55
+ // (e.g. "integration:google:access_token" → service="integration:google", field="access_token")
56
+ const colonIdx = account.lastIndexOf(":");
57
+ if (colonIdx > 0 && colonIdx < account.length - 1) {
58
+ const service = account.slice(0, colonIdx);
59
+ const field = account.slice(colonIdx + 1);
60
+ return `${CREDENTIAL_PREFIX}${service}/${field}`;
61
+ }
62
+
63
+ // Unrecognized format — return as-is (will likely fail lookup, which is fine)
64
+ return account;
65
+ }
66
+
24
67
  // ---------------------------------------------------------------------------
25
68
  // Auth
26
69
  // ---------------------------------------------------------------------------
@@ -136,8 +179,9 @@ export async function handleCredentialRoute(
136
179
 
137
180
  const results: Array<{ account: string; ok: boolean }> = [];
138
181
  for (const entry of body.credentials as Array<{ account: string; value: string }>) {
139
- const ok = await backend.set(entry.account, entry.value);
140
- results.push({ account: entry.account, ok: !!ok });
182
+ const normalized = normalizeAccountKey(entry.account);
183
+ const ok = await backend.set(normalized, entry.value);
184
+ results.push({ account: normalized, ok: !!ok });
141
185
  }
142
186
 
143
187
  return new Response(
@@ -167,15 +211,17 @@ export async function handleCredentialRoute(
167
211
  return null; // Not a credential route
168
212
  }
169
213
 
170
- const account = decodeURIComponent(accountSegment.slice(1));
171
- if (!account) {
214
+ const rawAccount = decodeURIComponent(accountSegment.slice(1));
215
+ if (!rawAccount) {
172
216
  return new Response(
173
217
  JSON.stringify({ error: "Account name is required" }),
174
218
  { status: 400, headers: { "Content-Type": "application/json" } },
175
- );
176
- }
219
+ );
220
+ }
221
+
222
+ const account = normalizeAccountKey(rawAccount);
177
223
 
178
- switch (req.method) {
224
+ switch (req.method) {
179
225
  // GET /v1/credentials/:account — get credential value
180
226
  case "GET": {
181
227
  const value = await backend.get(account);
@@ -26,8 +26,8 @@
26
26
  import type {
27
27
  MakeAuthenticatedRequest,
28
28
  MakeAuthenticatedRequestResponse,
29
- } from "@vellumai/ces-contracts";
30
- import { HandleType, parseHandle, hashProposal } from "@vellumai/ces-contracts";
29
+ } from "@vellumai/service-contracts/credential-rpc";
30
+ import { HandleType, parseHandle, hashProposal } from "@vellumai/service-contracts/credential-rpc";
31
31
  import type { InjectionTemplate } from "@vellumai/credential-storage";
32
32
 
33
33
  import { evaluateHttpPolicy, type PolicyResult } from "./policy.js";
@@ -18,7 +18,7 @@
18
18
  * credential.
19
19
  */
20
20
 
21
- import { hashProposal, type HttpGrantProposal } from "@vellumai/ces-contracts";
21
+ import { hashProposal, type HttpGrantProposal } from "@vellumai/service-contracts/credential-rpc";
22
22
 
23
23
  import type { PersistentGrant, PersistentGrantStore } from "../grants/persistent-store.js";
24
24
  import type { TemporaryGrantStore } from "../grants/temporary-store.js";
package/src/main.ts CHANGED
@@ -21,9 +21,12 @@
21
21
 
22
22
  import { mkdirSync } from "node:fs";
23
23
  import { homedir } from "node:os";
24
- import { join } from "node:path";
24
+ import { dirname, join } from "node:path";
25
25
 
26
- import { CES_PROTOCOL_VERSION, CesRpcMethod } from "@vellumai/ces-contracts";
26
+ import {
27
+ CES_PROTOCOL_VERSION,
28
+ CesRpcMethod,
29
+ } from "@vellumai/service-contracts/credential-rpc";
27
30
  import { StaticCredentialMetadataStore } from "@vellumai/credential-storage";
28
31
 
29
32
  import { AuditStore } from "./audit/store.js";
@@ -36,6 +39,7 @@ import {
36
39
  } from "./grants/rpc-handlers.js";
37
40
  import { TemporaryGrantStore } from "./grants/temporary-store.js";
38
41
  import { LocalMaterialiser } from "./materializers/local.js";
42
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
39
43
  import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
40
44
  import { createLocalOAuthLookup } from "./materializers/local-oauth-lookup.js";
41
45
  import { createLocalTokenRefreshFn } from "./materializers/local-token-refresh.js";
@@ -57,9 +61,14 @@ import {
57
61
  type RpcHandlerRegistry,
58
62
  type SessionIdRef,
59
63
  } from "./server.js";
60
- import { deleteBundleFromToolstore, publishBundle } from "./toolstore/publish.js";
64
+ import {
65
+ deleteBundleFromToolstore,
66
+ publishBundle,
67
+ } from "./toolstore/publish.js";
61
68
  import { validateSourceUrl } from "./toolstore/manifest.js";
62
69
  import { buildCesEgressHooks } from "./commands/egress-hooks.js";
70
+ import { CES_MIGRATIONS } from "./migrations/registry.js";
71
+ import { runCesMigrations } from "./migrations/runner.js";
63
72
 
64
73
  // ---------------------------------------------------------------------------
65
74
  // Data directory bootstrap
@@ -78,19 +87,46 @@ function ensureDataDirs(): void {
78
87
  }
79
88
 
80
89
  // ---------------------------------------------------------------------------
81
- // Vellum root resolution (mirrors assistant/src/util/platform.ts)
90
+ // Path resolution
82
91
  // ---------------------------------------------------------------------------
83
92
 
84
- function getVellumRootDir(): string {
85
- const baseDataDir = process.env["BASE_DATA_DIR"]?.trim();
86
- return join(baseDataDir || homedir(), ".vellum");
93
+ /**
94
+ * Resolve the workspace directory.
95
+ *
96
+ * Priority:
97
+ * 1. `VELLUM_WORKSPACE_DIR` env var (set by the platform template)
98
+ * 2. Default: `~/.vellum/workspace`
99
+ */
100
+ function getWorkspaceDir(): string {
101
+ return (
102
+ process.env["VELLUM_WORKSPACE_DIR"]?.trim() ||
103
+ join(homedir(), ".vellum", "workspace")
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Resolve the CES security directory (contains key stores, encryption data).
109
+ *
110
+ * Priority:
111
+ * 1. `CREDENTIAL_SECURITY_DIR` env var (set by the platform template for
112
+ * the CES container — `/ces-security` in managed mode)
113
+ * 2. Default: `~/.vellum/protected` (local mode)
114
+ */
115
+ function getSecurityDir(): string {
116
+ return (
117
+ process.env["CREDENTIAL_SECURITY_DIR"]?.trim() ||
118
+ join(homedir(), ".vellum", "protected")
119
+ );
87
120
  }
88
121
 
89
122
  // ---------------------------------------------------------------------------
90
123
  // Build RPC handler registry
91
124
  // ---------------------------------------------------------------------------
92
125
 
93
- function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
126
+ function buildHandlers(
127
+ sessionIdRef: SessionIdRef,
128
+ secureKeyBackend: SecureKeyBackend,
129
+ ): RpcHandlerRegistry {
94
130
  // -- Grant stores ----------------------------------------------------------
95
131
  const persistentGrantStore = new PersistentGrantStore(
96
132
  getCesGrantsDir("local"),
@@ -106,10 +142,9 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
106
142
  // -- Credential backend (local) --------------------------------------------
107
143
  // In local mode CES shares the filesystem with the assistant and can access
108
144
  // the same credential metadata and secure-key stores.
109
- const vellumRoot = getVellumRootDir();
145
+ const workspaceDir = getWorkspaceDir();
110
146
  const credentialMetadataPath = join(
111
- vellumRoot,
112
- "workspace",
147
+ workspaceDir,
113
148
  "data",
114
149
  "credentials",
115
150
  "metadata.json",
@@ -120,33 +155,27 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
120
155
 
121
156
  // Read-only OAuth connection lookup backed by the assistant's SQLite
122
157
  // database. CES opens the database in read-only mode.
123
- const oauthConnections = createLocalOAuthLookup(vellumRoot);
124
-
125
- // CES-native SecureKeyBackend that reads from the assistant's encrypted
126
- // key store file. Read-only — CES never writes or deletes keys.
127
- const secureKeyBackend = createLocalSecureKeyBackend(vellumRoot);
158
+ const oauthConnections = createLocalOAuthLookup(workspaceDir);
128
159
 
129
160
  const localMaterialiser = new LocalMaterialiser({
130
161
  secureKeyBackend,
131
- tokenRefreshFn: createLocalTokenRefreshFn(vellumRoot, secureKeyBackend),
162
+ tokenRefreshFn: createLocalTokenRefreshFn(workspaceDir, secureKeyBackend),
132
163
  });
133
164
 
134
165
  // -- Build handler registry ------------------------------------------------
135
166
 
136
167
  // Start with the HTTP handler (make_authenticated_request)
137
- const handlers = buildHandlersWithHttp(
138
- {
139
- persistentGrantStore,
140
- temporaryGrantStore,
141
- localMaterialiser,
142
- localSubjectDeps: {
143
- metadataStore,
144
- oauthConnections,
145
- },
146
- auditStore,
147
- sessionId: sessionIdRef,
168
+ const handlers = buildHandlersWithHttp({
169
+ persistentGrantStore,
170
+ temporaryGrantStore,
171
+ localMaterialiser,
172
+ localSubjectDeps: {
173
+ metadataStore,
174
+ oauthConnections,
148
175
  },
149
- );
176
+ auditStore,
177
+ sessionId: sessionIdRef,
178
+ });
150
179
 
151
180
  // Register run_authenticated_command handler
152
181
  registerCommandExecutionHandler(handlers, {
@@ -174,7 +203,9 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
174
203
  }
175
204
  }
176
205
 
177
- const matResult = await localMaterialiser.materialise(subjectResult.subject);
206
+ const matResult = await localMaterialiser.materialise(
207
+ subjectResult.subject,
208
+ );
178
209
  if (!matResult.ok) {
179
210
  return { ok: false as const, error: matResult.error };
180
211
  }
@@ -189,11 +220,19 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
189
220
  cesMode: "local",
190
221
  egressHooks: buildCesEgressHooks(),
191
222
  },
192
- defaultWorkspaceDir: join(vellumRoot, "workspace"),
223
+ defaultWorkspaceDir: workspaceDir,
193
224
  });
194
225
 
195
226
  // Register manage_secure_command_tool handler
196
- const toolRegistry = new Map<string, { toolName: string; credentialHandle: string; description: string; bundleDigest: string }>();
227
+ const toolRegistry = new Map<
228
+ string,
229
+ {
230
+ toolName: string;
231
+ credentialHandle: string;
232
+ description: string;
233
+ bundleDigest: string;
234
+ }
235
+ >();
197
236
 
198
237
  registerManageSecureCommandToolHandler(handlers, {
199
238
  downloadBundle: async (sourceUrl: string) => {
@@ -202,13 +241,17 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
202
241
  throw new Error(urlError);
203
242
  }
204
243
  const MAX_BUNDLE_SIZE = 100 * 1024 * 1024; // 100 MB
205
- const resp = await fetch(sourceUrl, { signal: AbortSignal.timeout(60_000) });
244
+ const resp = await fetch(sourceUrl, {
245
+ signal: AbortSignal.timeout(60_000),
246
+ });
206
247
  if (!resp.ok) {
207
248
  throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
208
249
  }
209
250
  const contentLength = resp.headers.get("content-length");
210
251
  if (contentLength && parseInt(contentLength, 10) > MAX_BUNDLE_SIZE) {
211
- throw new Error(`Bundle too large: ${contentLength} bytes (max ${MAX_BUNDLE_SIZE})`);
252
+ throw new Error(
253
+ `Bundle too large: ${contentLength} bytes (max ${MAX_BUNDLE_SIZE})`,
254
+ );
212
255
  }
213
256
  // Stream the body and enforce the size limit on actual bytes received,
214
257
  // since Content-Length can be absent (chunked encoding) or lie.
@@ -221,7 +264,9 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
221
264
  for await (const chunk of body) {
222
265
  totalBytes += chunk.byteLength;
223
266
  if (totalBytes > MAX_BUNDLE_SIZE) {
224
- throw new Error(`Bundle too large: received >${MAX_BUNDLE_SIZE} bytes (max ${MAX_BUNDLE_SIZE})`);
267
+ throw new Error(
268
+ `Bundle too large: received >${MAX_BUNDLE_SIZE} bytes (max ${MAX_BUNDLE_SIZE})`,
269
+ );
225
270
  }
226
271
  chunks.push(chunk);
227
272
  }
@@ -232,7 +277,9 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
232
277
  const entry = toolRegistry.get(toolName);
233
278
  const removed = toolRegistry.delete(toolName);
234
279
  if (removed && entry?.bundleDigest) {
235
- const stillInUse = Array.from(toolRegistry.values()).some(t => t.bundleDigest === entry.bundleDigest);
280
+ const stillInUse = Array.from(toolRegistry.values()).some(
281
+ (t) => t.bundleDigest === entry.bundleDigest,
282
+ );
236
283
  if (!stillInUse) {
237
284
  deleteBundleFromToolstore(entry.bundleDigest, "local");
238
285
  }
@@ -248,50 +295,57 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
248
295
  handlers[CesRpcMethod.RecordGrant] = createRecordGrantHandler({
249
296
  persistentGrantStore,
250
297
  temporaryGrantStore,
251
- }) as typeof handlers[string];
298
+ }) as (typeof handlers)[string];
252
299
 
253
300
  handlers[CesRpcMethod.ListGrants] = createListGrantsHandler({
254
301
  persistentGrantStore,
255
- }) as typeof handlers[string];
302
+ }) as (typeof handlers)[string];
256
303
 
257
304
  handlers[CesRpcMethod.RevokeGrant] = createRevokeGrantHandler({
258
305
  persistentGrantStore,
259
- }) as typeof handlers[string];
306
+ }) as (typeof handlers)[string];
260
307
 
261
308
  // Register audit record handler
262
309
  handlers[CesRpcMethod.ListAuditRecords] = createListAuditRecordsHandler({
263
310
  auditStore,
264
- }) as typeof handlers[string];
311
+ }) as (typeof handlers)[string];
265
312
 
266
313
  // Register credential CRUD handlers
267
314
  handlers[CesRpcMethod.GetCredential] = (async (req: { account: string }) => {
268
315
  const value = await secureKeyBackend.get(req.account);
269
316
  return { found: value !== undefined, value };
270
- }) as typeof handlers[string];
317
+ }) as (typeof handlers)[string];
271
318
 
272
- handlers[CesRpcMethod.SetCredential] = (async (req: { account: string; value: string }) => {
319
+ handlers[CesRpcMethod.SetCredential] = (async (req: {
320
+ account: string;
321
+ value: string;
322
+ }) => {
273
323
  const ok = await secureKeyBackend.set(req.account, req.value);
274
324
  return { ok };
275
- }) as typeof handlers[string];
325
+ }) as (typeof handlers)[string];
276
326
 
277
- handlers[CesRpcMethod.DeleteCredential] = (async (req: { account: string }) => {
327
+ handlers[CesRpcMethod.DeleteCredential] = (async (req: {
328
+ account: string;
329
+ }) => {
278
330
  const result = await secureKeyBackend.delete(req.account);
279
331
  return { result };
280
- }) as typeof handlers[string];
332
+ }) as (typeof handlers)[string];
281
333
 
282
334
  handlers[CesRpcMethod.ListCredentials] = (async () => {
283
335
  const accounts = await secureKeyBackend.list();
284
336
  return { accounts };
285
- }) as typeof handlers[string];
337
+ }) as (typeof handlers)[string];
286
338
 
287
- handlers[CesRpcMethod.BulkSetCredentials] = (async (req: { credentials: Array<{ account: string; value: string }> }) => {
339
+ handlers[CesRpcMethod.BulkSetCredentials] = (async (req: {
340
+ credentials: Array<{ account: string; value: string }>;
341
+ }) => {
288
342
  const results = [];
289
343
  for (const { account, value } of req.credentials) {
290
344
  const ok = await secureKeyBackend.set(account, value);
291
345
  results.push({ account, ok });
292
346
  }
293
347
  return { results };
294
- }) as typeof handlers[string];
348
+ }) as (typeof handlers)[string];
295
349
 
296
350
  return handlers;
297
351
  }
@@ -306,7 +360,9 @@ async function main(): Promise<void> {
306
360
  initLogger({ dir: getCesLogDir(), retentionDays: 30 });
307
361
  const log = getLogger("main");
308
362
 
309
- log.info(`Starting CES v${CES_PROTOCOL_VERSION} (local mode, stdio transport)`);
363
+ log.info(
364
+ `Starting CES v${CES_PROTOCOL_VERSION} (local mode, stdio transport)`,
365
+ );
310
366
 
311
367
  const controller = new AbortController();
312
368
 
@@ -318,11 +374,25 @@ async function main(): Promise<void> {
318
374
  process.on("SIGTERM", shutdown);
319
375
  process.on("SIGINT", shutdown);
320
376
 
377
+ // Build the credential backend and run one-time migrations before starting
378
+ // the RPC server. Migrations complete synchronously before any connection
379
+ // is accepted — the backend is then passed to buildHandlers so it is not
380
+ // re-instantiated.
381
+ const secureKeyBackend = createLocalSecureKeyBackend(
382
+ dirname(getSecurityDir()),
383
+ );
384
+ await runCesMigrations(
385
+ getCesDataRoot("local"),
386
+ secureKeyBackend,
387
+ CES_MIGRATIONS,
388
+ );
389
+ log.info("CES local startup: migrations complete");
390
+
321
391
  // Build the handler registry with all available RPC implementations.
322
392
  // Use a mutable ref so audit records capture the handshake session ID
323
393
  // once it's negotiated (the handshake completes before any RPC call).
324
394
  const sessionIdRef: SessionIdRef = { current: `ces-local-${Date.now()}` };
325
- const handlers = buildHandlers(sessionIdRef);
395
+ const handlers = buildHandlers(sessionIdRef, secureKeyBackend);
326
396
 
327
397
  const rpcLog = getLogger("rpc");
328
398
  const server = new CesRpcServer({
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Error message constants for managed-mode CES.
3
3
  *
4
- * Re-exported from @vellumai/ces-contracts so both the assistant and
4
+ * Re-exported from @vellumai/service-contracts so both the assistant and
5
5
  * credential-executor can share the constant via the approved shared-code
6
6
  * path without violating the hard process-boundary isolation.
7
7
  */
8
8
 
9
- export { MANAGED_LOCAL_STATIC_REJECTION_ERROR } from "@vellumai/ces-contracts";
9
+ export { MANAGED_LOCAL_STATIC_REJECTION_ERROR } from "@vellumai/service-contracts/credential-rpc";
@@ -23,10 +23,10 @@ export interface ApiKeyRef {
23
23
  }
24
24
 
25
25
  /**
26
- * Mutable reference to the platform assistant ID. For warm-pool pods the
27
- * PLATFORM_ASSISTANT_ID env var is empty at startup; the assistant forwards
28
- * the ID via the handshake or update_managed_credential RPC after
29
- * provisioning, and `.current` is updated so lazy getters pick it up.
26
+ * Mutable reference to the platform assistant ID. The assistant ID is not
27
+ * available at CES startup (warm-pool pods); the assistant forwards it via
28
+ * the handshake or update_managed_credential RPC after provisioning, and
29
+ * `.current` is updated so lazy getters pick it up.
30
30
  */
31
31
  export interface AssistantIdRef {
32
32
  current: string;
@@ -22,7 +22,7 @@ import { createServer as createNetServer, type Socket } from "node:net";
22
22
  import { dirname, join } from "node:path";
23
23
  import { Readable, Writable } from "node:stream";
24
24
 
25
- import { CES_PROTOCOL_VERSION, CesRpcMethod } from "@vellumai/ces-contracts";
25
+ import { CES_PROTOCOL_VERSION, CesRpcMethod } from "@vellumai/service-contracts/credential-rpc";
26
26
 
27
27
  import { AuditStore } from "./audit/store.js";
28
28
  import { PersistentGrantStore } from "./grants/persistent-store.js";
@@ -56,13 +56,15 @@ import { validateSourceUrl } from "./toolstore/manifest.js";
56
56
  import { buildCesEgressHooks } from "./commands/egress-hooks.js";
57
57
  import { resolveManagedSubject } from "./subjects/managed.js";
58
58
  import { materializeManagedToken } from "./materializers/managed-platform.js";
59
- import { HandleType, parseHandle } from "@vellumai/ces-contracts";
59
+ import { HandleType, parseHandle } from "@vellumai/service-contracts/credential-rpc";
60
60
  import { buildLazyGetters, type ApiKeyRef, type AssistantIdRef } from "./managed-lazy-getters.js";
61
61
  import { MANAGED_LOCAL_STATIC_REJECTION_ERROR } from "./managed-errors.js";
62
62
  import type { SecureKeyBackend } from "@vellumai/credential-storage";
63
63
  import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
64
64
  import { handleCredentialRoute, type CredentialRouteDeps } from "./http/credential-routes.js";
65
65
  import { handleLogExportRoute } from "./http/log-export-routes.js";
66
+ import { CES_MIGRATIONS } from "./migrations/registry.js";
67
+ import { runCesMigrations } from "./migrations/runner.js";
66
68
 
67
69
  // ---------------------------------------------------------------------------
68
70
  // Logging
@@ -543,6 +545,10 @@ async function main(): Promise<void> {
543
545
  const vellumRoot = join(assistantDataMount, ".vellum");
544
546
  const secureKeyBackend = createLocalSecureKeyBackend(vellumRoot);
545
547
 
548
+ // Run one-time credential store migrations before accepting connections.
549
+ await runCesMigrations(getCesDataRoot("managed"), secureKeyBackend, CES_MIGRATIONS);
550
+ log.info("CES managed startup: migrations complete");
551
+
546
552
  // Set up credential CRUD routes if a service token is configured.
547
553
  // The assistant and gateway use CES_SERVICE_TOKEN to authenticate
548
554
  // credential management requests over HTTP.
@@ -586,7 +592,7 @@ async function main(): Promise<void> {
586
592
  // are available to handlers at call time (after the handshake completes).
587
593
  const sessionIdRef: SessionIdRef = { current: `ces-managed-${Date.now()}` };
588
594
  const apiKeyRef: ApiKeyRef = { current: "" };
589
- const assistantIdRef: AssistantIdRef = { current: process.env["PLATFORM_ASSISTANT_ID"] ?? "" };
595
+ const assistantIdRef: AssistantIdRef = { current: "" };
590
596
  const handlers = buildHandlers(sessionIdRef, apiKeyRef, assistantIdRef, secureKeyBackend);
591
597
 
592
598
  const rpcLog = getLogger("rpc");
@@ -65,12 +65,12 @@ function rowToRecord(row: OAuthConnectionRow): OAuthConnectionRecord {
65
65
  * Create a read-only OAuth connection lookup backed by the assistant's
66
66
  * SQLite database.
67
67
  *
68
- * @param vellumRoot - The Vellum root directory (e.g. `~/.vellum`).
68
+ * @param workspaceDir - The workspace directory (e.g. `~/.vellum/workspace`).
69
69
  */
70
70
  export function createLocalOAuthLookup(
71
- vellumRoot: string,
71
+ workspaceDir: string,
72
72
  ): OAuthConnectionLookup {
73
- const dbPath = join(vellumRoot, "workspace", "data", "db", "assistant.db");
73
+ const dbPath = join(workspaceDir, "data", "db", "assistant.db");
74
74
 
75
75
  return {
76
76
  getById(connectionId: string): OAuthConnectionRecord | undefined {
@@ -80,9 +80,10 @@ export function createLocalOAuthLookup(
80
80
  try {
81
81
  db = new Database(dbPath, { readonly: true });
82
82
  const row = db
83
- .query<OAuthConnectionRow, [string, string]>(
84
- `SELECT * FROM oauth_connections WHERE id = ? AND status = ? LIMIT 1`,
85
- )
83
+ .query<
84
+ OAuthConnectionRow,
85
+ [string, string]
86
+ >(`SELECT * FROM oauth_connections WHERE id = ? AND status = ? LIMIT 1`)
86
87
  .get(connectionId, "active");
87
88
 
88
89
  if (!row) return undefined;
@@ -88,20 +88,24 @@ async function resolveRefreshConfig(
88
88
 
89
89
  // 1. Look up the connection to get oauth_app_id and provider_key
90
90
  const conn = db
91
- .query<OAuthConnectionRow, [string, string]>(
92
- `SELECT id, oauth_app_id, provider_key FROM oauth_connections WHERE id = ? AND status = ? LIMIT 1`,
93
- )
91
+ .query<
92
+ OAuthConnectionRow,
93
+ [string, string]
94
+ >(`SELECT id, oauth_app_id, provider_key FROM oauth_connections WHERE id = ? AND status = ? LIMIT 1`)
94
95
  .get(connectionId, "active");
95
96
 
96
97
  if (!conn) {
97
- return { error: `No active OAuth connection found for "${connectionId}"` };
98
+ return {
99
+ error: `No active OAuth connection found for "${connectionId}"`,
100
+ };
98
101
  }
99
102
 
100
103
  // 2. Look up the app to get client_id and client_secret_credential_path
101
104
  const app = db
102
- .query<OAuthAppRow, [string]>(
103
- `SELECT id, provider_key, client_id, client_secret_credential_path FROM oauth_apps WHERE id = ? LIMIT 1`,
104
- )
105
+ .query<
106
+ OAuthAppRow,
107
+ [string]
108
+ >(`SELECT id, provider_key, client_id, client_secret_credential_path FROM oauth_apps WHERE id = ? LIMIT 1`)
105
109
  .get(conn.oauth_app_id);
106
110
 
107
111
  if (!app) {
@@ -110,9 +114,10 @@ async function resolveRefreshConfig(
110
114
 
111
115
  // 3. Look up the provider to get token_url and auth method
112
116
  const provider = db
113
- .query<OAuthProviderRow, [string]>(
114
- `SELECT provider_key, token_url, refresh_url, token_endpoint_auth_method, token_exchange_body_format FROM oauth_providers WHERE provider_key = ? LIMIT 1`,
115
- )
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`)
116
121
  .get(conn.provider_key);
117
122
 
118
123
  if (!provider) {
@@ -123,7 +128,9 @@ async function resolveRefreshConfig(
123
128
  const tokenUrl = provider.refresh_url || provider.token_url;
124
129
 
125
130
  if (!tokenUrl || !app.client_id) {
126
- return { error: `Missing OAuth2 refresh config for "${conn.provider_key}"` };
131
+ return {
132
+ error: `Missing OAuth2 refresh config for "${conn.provider_key}"`,
133
+ };
127
134
  }
128
135
 
129
136
  // 4. Retrieve the client secret from secure storage
@@ -131,8 +138,11 @@ async function resolveRefreshConfig(
131
138
  app.client_secret_credential_path,
132
139
  );
133
140
 
134
- const authMethod = (provider.token_endpoint_auth_method as TokenEndpointAuthMethod | null) ?? "client_secret_post";
135
- const bodyFormat = (provider.token_exchange_body_format as "form" | "json" | null) ?? "form";
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";
136
146
 
137
147
  return {
138
148
  tokenUrl,
@@ -238,10 +248,10 @@ async function performTokenRefresh(
238
248
  * @param secureKeyBackend - Backend for retrieving the OAuth client secret.
239
249
  */
240
250
  export function createLocalTokenRefreshFn(
241
- vellumRoot: string,
251
+ workspaceDir: string,
242
252
  secureKeyBackend: SecureKeyBackend,
243
253
  ): TokenRefreshFn {
244
- const dbPath = join(vellumRoot, "workspace", "data", "db", "assistant.db");
254
+ const dbPath = join(workspaceDir, "data", "db", "assistant.db");
245
255
 
246
256
  return async (
247
257
  connectionId: string,
@@ -32,7 +32,7 @@ import {
32
32
  RefreshDeduplicator,
33
33
  persistRefreshedTokens,
34
34
  } from "@vellumai/credential-storage";
35
- import { HandleType } from "@vellumai/ces-contracts";
35
+ import { HandleType } from "@vellumai/service-contracts/credential-rpc";
36
36
 
37
37
  import type {
38
38
  ResolvedLocalSubject,
@@ -0,0 +1,19 @@
1
+ import type { CesMigration } from "./types.js";
2
+
3
+ /**
4
+ * No-op foundation migration.
5
+ *
6
+ * Establishes the CES migration system and seeds the checkpoint file for
7
+ * all existing installations. The next real migration (API key → credential
8
+ * key rekeying) will follow this one.
9
+ */
10
+ export const noOpMigration: CesMigration = {
11
+ id: "001-no-op",
12
+ description: "Seed CES migration checkpoint (no-op foundation)",
13
+ run(_backend): void {
14
+ // Intentionally empty — seeds the checkpoint file for existing installs.
15
+ },
16
+ down(_backend): void {
17
+ // Intentionally empty — nothing to reverse.
18
+ },
19
+ };