open-managed-agents 0.1.1 → 0.2.0

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 (29) hide show
  1. package/README.md +32 -7
  2. package/dist/scripts/alpha-smoke.mjs +40 -2
  3. package/dist/scripts/oma-doctor.js +1 -1
  4. package/dist/scripts/oma-models.js +20 -16
  5. package/dist/src/control-plane/app.js +75 -4
  6. package/dist/src/control-plane/events/service.js +82 -8
  7. package/dist/src/control-plane/models/catalog.js +29 -13
  8. package/dist/src/control-plane/models/credential-store.js +203 -0
  9. package/dist/src/control-plane/sessions/pi/mcp/client.js +5 -1
  10. package/dist/src/control-plane/sessions/pi/runner.js +59 -32
  11. package/dist/src/control-plane/sessions/pi/sandbox/container-engine.js +32 -0
  12. package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +8 -0
  13. package/dist/src/control-plane/sessions/pi/sandbox/docker.js +20 -5
  14. package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +18 -3
  15. package/dist/src/control-plane/sessions/pi/sandbox/provider.js +16 -9
  16. package/dist/src/control-plane/sessions/service.js +5 -0
  17. package/dist/src/control-plane/vaults/console-mcp-oauth.js +545 -0
  18. package/dist/src/control-plane/vaults/service.js +4 -1
  19. package/dist/src/control-plane/vaults/store.js +8 -2
  20. package/dist/src/main.js +1 -1
  21. package/dist/ui/managed-agents-console/README.md +22 -15
  22. package/dist/ui/managed-agents-console/docs/vaults.md +16 -2
  23. package/dist/ui/managed-agents-console/index.html +4 -0
  24. package/dist/ui/managed-agents-console/src/api.js +40 -0
  25. package/dist/ui/managed-agents-console/src/console.css +4 -1
  26. package/dist/ui/managed-agents-console/src/forms.jsx +79 -16
  27. package/dist/ui/managed-agents-console/src/session-vaults-data.js +40 -0
  28. package/dist/ui/managed-agents-console/src/vaults.jsx +104 -9
  29. package/package.json +15 -7
@@ -30,6 +30,7 @@ export class DefaultSessionService {
30
30
  startedAt = new Date().toISOString();
31
31
  deleteSessionRows;
32
32
  assertDeletable;
33
+ awaitDeletable;
33
34
  idempotencyLedger;
34
35
  createSessionRowsWithIdempotency;
35
36
  pendingSnapshotCleanupRetryDelayMs;
@@ -48,6 +49,7 @@ export class DefaultSessionService {
48
49
  throw new Error("DefaultSessionService requires assertDeletable for safe session deletion");
49
50
  }
50
51
  this.assertDeletable = opts.assertDeletable;
52
+ this.awaitDeletable = opts.awaitDeletable;
51
53
  this.maxActiveSessionsPerWorkspace = opts.maxActiveSessionsPerWorkspace;
52
54
  this.onAdmissionRejected = opts.onAdmissionRejected;
53
55
  this.modelAvailability = opts.modelAvailability;
@@ -348,6 +350,9 @@ export class DefaultSessionService {
348
350
  // Domain-owned liveness invariant: reject deleting a running session before
349
351
  // any row/file mutation, so no caller (route or internal) can tear down a
350
352
  // live runtime. Synchronous, so it runs in the same tick as the row removal.
353
+ // A session that already reported idle may still be collecting outputs;
354
+ // let that settle first so a client deleting after idle is not rejected.
355
+ await this.awaitDeletable?.(workspaceId, sessionId);
351
356
  this.assertDeletable(workspaceId, sessionId);
352
357
  const result = this.deleteSessionRows?.(workspaceId, sessionId) ??
353
358
  this.deleteSessionRowsWithDefaultStore(workspaceId, sessionId);
@@ -0,0 +1,545 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { auth, selectClientAuthMethod, } from "@modelcontextprotocol/sdk/client/auth.js";
3
+ import { invalidRequest, notFound } from "../errors.js";
4
+ export const CONSOLE_MCP_OAUTH_FLOW_TTL_MS = 10 * 60 * 1000;
5
+ export const CONSOLE_MCP_OAUTH_MAX_BODY_BYTES = 64 * 1024;
6
+ export class ConsoleMcpOauthService {
7
+ vaults;
8
+ fetch;
9
+ opts;
10
+ flows = new Map();
11
+ flowIdsByState = new Map();
12
+ constructor(vaults, fetch, opts = {}) {
13
+ this.vaults = vaults;
14
+ this.fetch = fetch;
15
+ this.opts = opts;
16
+ }
17
+ async startConnect(workspaceId, input, callbackUrl) {
18
+ const body = objectInput(input);
19
+ const vaultId = stringField(body, "vault_id");
20
+ const serverUrl = mcpServerUrl(stringField(body, "mcp_server_url"));
21
+ const displayName = optionalStringField(body, "display_name");
22
+ this.vaults.retrieveVault(workspaceId, vaultId);
23
+ return this.start({
24
+ workspaceId,
25
+ vaultId,
26
+ ...(displayName === undefined ? {} : { displayName }),
27
+ serverUrl,
28
+ callbackUrl,
29
+ operation: "connect",
30
+ });
31
+ }
32
+ async startReauthorize(workspaceId, input, callbackUrl) {
33
+ const body = objectInput(input);
34
+ const vaultId = stringField(body, "vault_id");
35
+ const credentialId = stringField(body, "credential_id");
36
+ const credential = this.vaults.retrieveCredential(workspaceId, vaultId, credentialId);
37
+ if (credential.archived_at !== null)
38
+ throw invalidRequest("Credential is archived.");
39
+ if (credential.auth.type !== "mcp_oauth") {
40
+ throw invalidRequest("Only MCP OAuth credentials can be reauthorized");
41
+ }
42
+ const oauth = this.vaults.readOauthValidationSnapshot(workspaceId, vaultId, credentialId);
43
+ if (oauth?.refresh === undefined) {
44
+ throw invalidRequest("This OAuth credential has no reusable client registration");
45
+ }
46
+ // Without the connect-time authorization server, a hostile MCP server
47
+ // could echo the stored token endpoint and take the redirect (#257).
48
+ if (oauth.refresh.authorizationServer === undefined) {
49
+ throw invalidRequest("This OAuth credential was connected before OMA recorded its authorization server. Delete it and connect again to reauthorize.");
50
+ }
51
+ return this.start({
52
+ workspaceId,
53
+ vaultId,
54
+ credentialId,
55
+ serverUrl: credential.auth.mcp_server_url,
56
+ callbackUrl,
57
+ operation: "reauthorize",
58
+ boundTokenEndpoint: oauth.refresh.tokenEndpoint,
59
+ boundAuthorizationServer: oauth.refresh.authorizationServer,
60
+ clientInformation: {
61
+ client_id: oauth.refresh.clientId,
62
+ ...(oauth.secrets.clientSecret === undefined
63
+ ? {}
64
+ : { client_secret: oauth.secrets.clientSecret }),
65
+ },
66
+ });
67
+ }
68
+ status(workspaceId, flowId) {
69
+ const flow = this.flowForWorkspace(workspaceId, flowId);
70
+ this.expire(flow);
71
+ return publicStatus(flow);
72
+ }
73
+ deny(state) {
74
+ const flow = this.consumeCallbackState(state);
75
+ flow.status = "failed";
76
+ flow.error = {
77
+ code: "authorization_denied",
78
+ message: "Authorization was denied or cancelled at the provider.",
79
+ };
80
+ flow.provider.clearSecrets();
81
+ return { flowId: flow.id, ok: false };
82
+ }
83
+ async complete(state, code) {
84
+ const flow = this.consumeCallbackState(state);
85
+ if (!code) {
86
+ flow.status = "failed";
87
+ flow.error = {
88
+ code: "callback_invalid",
89
+ message: "The provider callback did not include an authorization code.",
90
+ };
91
+ return { flowId: flow.id, ok: false };
92
+ }
93
+ flow.status = "completing";
94
+ let safetyFailure;
95
+ try {
96
+ const result = await auth(flow.provider, {
97
+ serverUrl: flow.serverUrl,
98
+ authorizationCode: code,
99
+ fetchFn: this.oauthFetch((error) => { safetyFailure = error; }),
100
+ });
101
+ if (result !== "AUTHORIZED" || flow.provider.savedTokens === undefined) {
102
+ throw new Error("OAuth token exchange did not produce tokens");
103
+ }
104
+ flow.credential = this.persist(flow, flow.provider.savedTokens);
105
+ flow.status = "connected";
106
+ }
107
+ catch (error) {
108
+ flow.status = "failed";
109
+ flow.error = safetyFailure ?? classifyOauthError(error, "token_exchange_failed");
110
+ }
111
+ finally {
112
+ flow.provider.clearSecrets();
113
+ }
114
+ return { flowId: flow.id, ok: flow.status === "connected" };
115
+ }
116
+ async start(input) {
117
+ this.cleanup();
118
+ const now = this.now();
119
+ const flowId = opaqueId("oauth_flow");
120
+ const state = opaqueId("oauth_state");
121
+ const provider = new FlowProvider({
122
+ redirectUrl: input.callbackUrl,
123
+ state,
124
+ clientInformation: input.clientInformation,
125
+ boundTokenEndpoint: input.boundTokenEndpoint,
126
+ boundAuthorizationServer: input.boundAuthorizationServer,
127
+ });
128
+ const flow = {
129
+ id: flowId,
130
+ state,
131
+ workspaceId: input.workspaceId,
132
+ vaultId: input.vaultId,
133
+ ...(input.credentialId === undefined ? {} : { credentialId: input.credentialId }),
134
+ ...(input.displayName === undefined ? {} : { displayName: input.displayName }),
135
+ serverUrl: input.serverUrl,
136
+ redirectUrl: input.callbackUrl,
137
+ operation: input.operation,
138
+ createdAtMs: now.getTime(),
139
+ expiresAtMs: now.getTime() + (this.opts.flowTtlMs ?? CONSOLE_MCP_OAUTH_FLOW_TTL_MS),
140
+ status: "pending",
141
+ provider,
142
+ };
143
+ this.flows.set(flowId, flow);
144
+ this.flowIdsByState.set(state, flowId);
145
+ let safetyFailure;
146
+ try {
147
+ const result = await auth(provider, {
148
+ serverUrl: input.serverUrl,
149
+ fetchFn: this.oauthFetch((error) => { safetyFailure = error; }),
150
+ });
151
+ if (result !== "REDIRECT" || provider.authorizationUrl === undefined) {
152
+ throw new Error("OAuth provider did not return an authorization redirect");
153
+ }
154
+ return {
155
+ flow_id: flow.id,
156
+ authorization_url: provider.authorizationUrl.toString(),
157
+ expires_at: new Date(flow.expiresAtMs).toISOString(),
158
+ };
159
+ }
160
+ catch (error) {
161
+ this.flowIdsByState.delete(state);
162
+ provider.clearSecrets();
163
+ flow.status = "failed";
164
+ flow.error = safetyFailure ?? classifyOauthError(error, "provider_unsupported");
165
+ throw invalidRequest(flow.error.message);
166
+ }
167
+ }
168
+ oauthFetch(onSafetyFailure) {
169
+ return boundedOauthFetch(this.fetch, {
170
+ timeoutMs: this.opts.operationTimeoutMs ?? 30_000,
171
+ maxBodyBytes: this.opts.maxBodyBytes ?? CONSOLE_MCP_OAUTH_MAX_BODY_BYTES,
172
+ }, onSafetyFailure);
173
+ }
174
+ persist(flow, tokens) {
175
+ const client = flow.provider.savedClientInformation;
176
+ const discovery = flow.provider.savedDiscoveryState;
177
+ const tokenEndpoint = discovery?.authorizationServerMetadata?.token_endpoint;
178
+ const refreshToken = tokens.refresh_token;
179
+ const clientId = client?.client_id;
180
+ const expiresAt = tokens.expires_in === undefined
181
+ ? undefined
182
+ : new Date(this.now().getTime() + tokens.expires_in * 1000).toISOString();
183
+ try {
184
+ if (flow.operation === "reauthorize") {
185
+ return this.vaults.updateCredential(flow.workspaceId, flow.vaultId, flow.credentialId, {
186
+ auth: {
187
+ type: "mcp_oauth",
188
+ access_token: tokens.access_token,
189
+ expires_at: expiresAt ?? null,
190
+ ...(refreshToken === undefined
191
+ ? {}
192
+ : { refresh: { refresh_token: refreshToken } }),
193
+ },
194
+ });
195
+ }
196
+ const canRefresh = refreshToken !== undefined && clientId !== undefined && tokenEndpoint !== undefined;
197
+ const authType = client === undefined
198
+ ? "none"
199
+ : selectClientAuthMethod(client, discovery?.authorizationServerMetadata?.token_endpoint_auth_methods_supported ?? []);
200
+ return this.vaults.createCredential(flow.workspaceId, flow.vaultId, {
201
+ ...(flow.displayName === undefined ? {} : { display_name: flow.displayName }),
202
+ auth: {
203
+ type: "mcp_oauth",
204
+ mcp_server_url: flow.serverUrl,
205
+ access_token: tokens.access_token,
206
+ ...(expiresAt === undefined ? {} : { expires_at: expiresAt }),
207
+ ...(canRefresh
208
+ ? {
209
+ refresh: {
210
+ token_endpoint: tokenEndpoint,
211
+ client_id: clientId,
212
+ ...(tokens.scope === undefined ? {} : { scope: tokens.scope }),
213
+ refresh_token: refreshToken,
214
+ token_endpoint_auth: {
215
+ type: authType,
216
+ ...(client?.client_secret === undefined
217
+ ? {}
218
+ : { client_secret: client.client_secret }),
219
+ },
220
+ },
221
+ }
222
+ : {}),
223
+ },
224
+ }, discovery?.authorizationServerUrl === undefined
225
+ ? {}
226
+ : { oauthAuthorizationServer: discovery.authorizationServerUrl });
227
+ }
228
+ catch (error) {
229
+ throw Object.assign(new Error("OAuth completed, but OMA could not save the credential"), {
230
+ cause: error,
231
+ oauthPersistenceFailure: true,
232
+ });
233
+ }
234
+ }
235
+ consumeCallbackState(state) {
236
+ if (!state)
237
+ throw invalidRequest("The OAuth callback state is missing");
238
+ const flowId = this.flowIdsByState.get(state);
239
+ if (flowId === undefined)
240
+ throw invalidRequest("The OAuth callback is unknown or was already used");
241
+ const flow = this.flows.get(flowId);
242
+ if (flow === undefined || !safeEqual(state, flow.state)) {
243
+ throw invalidRequest("The OAuth callback is unknown or was already used");
244
+ }
245
+ this.flowIdsByState.delete(state);
246
+ if (this.expire(flow)) {
247
+ flow.error = {
248
+ code: "callback_expired",
249
+ message: "The OAuth connection expired. Start Connect again.",
250
+ };
251
+ throw invalidRequest(flow.error.message);
252
+ }
253
+ if (flow.status !== "pending") {
254
+ throw invalidRequest("The OAuth callback is unknown or was already used");
255
+ }
256
+ return flow;
257
+ }
258
+ flowForWorkspace(workspaceId, flowId) {
259
+ const flow = this.flows.get(flowId);
260
+ if (flow === undefined || flow.workspaceId !== workspaceId) {
261
+ throw notFound(`OAuth flow ${flowId} not found`);
262
+ }
263
+ return flow;
264
+ }
265
+ expire(flow) {
266
+ if (flow.status === "pending" && this.now().getTime() >= flow.expiresAtMs) {
267
+ this.flowIdsByState.delete(flow.state);
268
+ flow.provider.clearSecrets();
269
+ flow.status = "expired";
270
+ flow.error = {
271
+ code: "callback_expired",
272
+ message: "The OAuth connection expired. Start Connect again.",
273
+ };
274
+ return true;
275
+ }
276
+ return flow.status === "expired";
277
+ }
278
+ cleanup() {
279
+ const cutoff = this.now().getTime() - (this.opts.flowTtlMs ?? CONSOLE_MCP_OAUTH_FLOW_TTL_MS);
280
+ for (const [id, flow] of this.flows) {
281
+ this.expire(flow);
282
+ if (flow.expiresAtMs < cutoff)
283
+ this.flows.delete(id);
284
+ }
285
+ }
286
+ now() {
287
+ return this.opts.now?.() ?? new Date();
288
+ }
289
+ }
290
+ class FlowProvider {
291
+ input;
292
+ authorizationUrl;
293
+ savedClientInformation;
294
+ savedTokens;
295
+ savedCodeVerifier;
296
+ savedDiscoveryState;
297
+ clientMetadata;
298
+ constructor(input) {
299
+ this.input = input;
300
+ this.savedClientInformation = input.clientInformation;
301
+ this.clientMetadata = {
302
+ redirect_uris: [input.redirectUrl],
303
+ client_name: "Open Managed Agents",
304
+ grant_types: ["authorization_code", "refresh_token"],
305
+ response_types: ["code"],
306
+ token_endpoint_auth_method: "none",
307
+ };
308
+ }
309
+ get redirectUrl() {
310
+ return this.input.redirectUrl;
311
+ }
312
+ state() {
313
+ return this.input.state;
314
+ }
315
+ clientInformation() {
316
+ return this.savedClientInformation;
317
+ }
318
+ saveClientInformation(value) {
319
+ this.savedClientInformation = value;
320
+ }
321
+ tokens() {
322
+ return undefined;
323
+ }
324
+ saveTokens(value) {
325
+ this.savedTokens = value;
326
+ }
327
+ redirectToAuthorization(url) {
328
+ this.authorizationUrl = url;
329
+ }
330
+ saveCodeVerifier(value) {
331
+ this.savedCodeVerifier = value;
332
+ }
333
+ codeVerifier() {
334
+ if (this.savedCodeVerifier === undefined)
335
+ throw new Error("PKCE verifier is missing");
336
+ return this.savedCodeVerifier;
337
+ }
338
+ // The MCP server picks the authorization server, so on reauthorize a
339
+ // hostile or compromised server could point the stored client secret at
340
+ // its own token endpoint (GHSA-6qxp-vccf-f47h). Stored credentials carry no
341
+ // SDK issuer stamp, so bind to the token endpoint saved at connect time:
342
+ // the only place the SDK sends the secret, and to the authorization server
343
+ // recorded at connect, so a server echoing the token endpoint cannot take
344
+ // the browser redirect (code injection, #257). The SDK saves discovery
345
+ // state before the redirect and before any token request.
346
+ saveDiscoveryState(value) {
347
+ const bound = this.input.boundTokenEndpoint;
348
+ const tokenEndpoint = value.authorizationServerMetadata?.token_endpoint;
349
+ const boundServer = this.input.boundAuthorizationServer;
350
+ if ((bound !== undefined && (tokenEndpoint === undefined || !sameUrl(tokenEndpoint, bound))) ||
351
+ (boundServer !== undefined && !sameUrl(value.authorizationServerUrl, boundServer))) {
352
+ throw Object.assign(new Error("OAuth authorization server changed since this credential was connected"), {
353
+ oauthAuthorizationServerChanged: true,
354
+ });
355
+ }
356
+ this.savedDiscoveryState = value;
357
+ }
358
+ discoveryState() {
359
+ return this.savedDiscoveryState;
360
+ }
361
+ clearSecrets() {
362
+ this.savedTokens = undefined;
363
+ this.savedCodeVerifier = undefined;
364
+ if (this.savedClientInformation?.client_secret !== undefined) {
365
+ const { client_secret: _discarded, ...clientInformation } = this.savedClientInformation;
366
+ this.savedClientInformation = clientInformation;
367
+ }
368
+ }
369
+ }
370
+ function publicStatus(flow) {
371
+ const oauth = flow.credential?.auth.type === "mcp_oauth" ? flow.credential.auth : undefined;
372
+ return {
373
+ flow_id: flow.id,
374
+ status: flow.status,
375
+ expires_at: new Date(flow.expiresAtMs).toISOString(),
376
+ ...(flow.credential === undefined ? {} : { credential_id: flow.credential.id }),
377
+ ...(oauth === undefined ? {} : { refreshable: oauth.refresh !== undefined }),
378
+ ...(flow.error === undefined ? {} : { error: flow.error }),
379
+ };
380
+ }
381
+ function classifyOauthError(error, fallback) {
382
+ if (isObject(error) && error.oauthAuthorizationServerChanged === true) {
383
+ return {
384
+ code: "authorization_server_changed",
385
+ message: "The MCP server now names a different authorization server than when this credential was connected. Delete the credential and connect again if the change is expected.",
386
+ };
387
+ }
388
+ if (isObject(error) && error.oauthPersistenceFailure === true) {
389
+ return {
390
+ code: "credential_persist_failed",
391
+ message: "Authorization succeeded, but OMA could not save the credential.",
392
+ };
393
+ }
394
+ const message = error instanceof Error ? error.message.toLowerCase() : "";
395
+ if (message.includes("registration endpoint") ||
396
+ message.includes("client information") ||
397
+ message.includes("dynamic client registration")) {
398
+ return {
399
+ code: "provider_unsupported",
400
+ message: "This MCP provider does not support automatic client registration. A pre-registered client is not supported in this console flow yet.",
401
+ };
402
+ }
403
+ if (message.includes("fetch failed") ||
404
+ message.includes("timeout") ||
405
+ message.includes("aborted") ||
406
+ message.includes("egress denied") ||
407
+ message.includes("response too large")) {
408
+ return {
409
+ code: "provider_unreachable",
410
+ message: "OMA could not safely reach the MCP authorization service.",
411
+ };
412
+ }
413
+ return fallback === "provider_unsupported"
414
+ ? {
415
+ code: fallback,
416
+ message: "This MCP server did not expose a supported OAuth authorization flow.",
417
+ }
418
+ : {
419
+ code: fallback,
420
+ message: "The provider could not complete the OAuth token exchange. Start Connect again.",
421
+ };
422
+ }
423
+ function boundedOauthFetch(inner, opts, onSafetyFailure) {
424
+ return async (url, init = {}) => {
425
+ const timeout = new AbortController();
426
+ const timer = setTimeout(() => {
427
+ onSafetyFailure(oauthFetchSafetyFailure());
428
+ timeout.abort();
429
+ }, opts.timeoutMs);
430
+ const signal = init.signal == null
431
+ ? timeout.signal
432
+ : AbortSignal.any([init.signal, timeout.signal]);
433
+ try {
434
+ const response = await inner(url, { ...init, signal });
435
+ const declared = Number(response.headers.get("content-length"));
436
+ if (Number.isFinite(declared) && declared > opts.maxBodyBytes) {
437
+ await response.body?.cancel();
438
+ onSafetyFailure(oauthFetchSafetyFailure());
439
+ throw new Error("OAuth response too large");
440
+ }
441
+ let bytes;
442
+ try {
443
+ bytes = await readBoundedBody(response, opts.maxBodyBytes);
444
+ }
445
+ catch (error) {
446
+ if (error instanceof Error && error.message === "OAuth response too large") {
447
+ onSafetyFailure(oauthFetchSafetyFailure());
448
+ }
449
+ throw error;
450
+ }
451
+ return new Response(bytes, {
452
+ status: response.status,
453
+ statusText: response.statusText,
454
+ headers: response.headers,
455
+ });
456
+ }
457
+ finally {
458
+ clearTimeout(timer);
459
+ }
460
+ };
461
+ }
462
+ function oauthFetchSafetyFailure() {
463
+ return {
464
+ code: "provider_unreachable",
465
+ message: "OMA could not safely reach the MCP authorization service.",
466
+ };
467
+ }
468
+ async function readBoundedBody(response, maxBodyBytes) {
469
+ if (response.body === null)
470
+ return new Uint8Array();
471
+ const reader = response.body.getReader();
472
+ const chunks = [];
473
+ let total = 0;
474
+ try {
475
+ while (true) {
476
+ const { done, value } = await reader.read();
477
+ if (done)
478
+ break;
479
+ total += value.byteLength;
480
+ if (total > maxBodyBytes)
481
+ throw new Error("OAuth response too large");
482
+ chunks.push(value);
483
+ }
484
+ }
485
+ catch (error) {
486
+ await reader.cancel().catch(() => undefined);
487
+ throw error;
488
+ }
489
+ const bytes = new Uint8Array(total);
490
+ let offset = 0;
491
+ for (const chunk of chunks) {
492
+ bytes.set(chunk, offset);
493
+ offset += chunk.byteLength;
494
+ }
495
+ return bytes;
496
+ }
497
+ function opaqueId(prefix) {
498
+ return `${prefix}_${randomBytes(24).toString("base64url")}`;
499
+ }
500
+ function safeEqual(left, right) {
501
+ const a = Buffer.from(left);
502
+ const b = Buffer.from(right);
503
+ return a.length === b.length && timingSafeEqual(a, b);
504
+ }
505
+ function mcpServerUrl(value) {
506
+ try {
507
+ const url = new URL(value);
508
+ if (url.protocol !== "http:" && url.protocol !== "https:")
509
+ throw new Error();
510
+ if (url.username || url.password || url.hash)
511
+ throw new Error();
512
+ return url.toString();
513
+ }
514
+ catch {
515
+ throw invalidRequest("`mcp_server_url` must be an http(s) URL without userinfo or a fragment");
516
+ }
517
+ }
518
+ function objectInput(input) {
519
+ if (!isObject(input))
520
+ throw invalidRequest("Request body must be a JSON object");
521
+ return input;
522
+ }
523
+ function stringField(input, field) {
524
+ const value = input[field];
525
+ if (typeof value !== "string" || value.trim().length === 0) {
526
+ throw invalidRequest(`\`${field}\` must be a non-empty string`);
527
+ }
528
+ return value.trim();
529
+ }
530
+ function optionalStringField(input, field) {
531
+ if (input[field] === undefined)
532
+ return undefined;
533
+ return stringField(input, field);
534
+ }
535
+ function isObject(value) {
536
+ return typeof value === "object" && value !== null && !Array.isArray(value);
537
+ }
538
+ function sameUrl(a, b) {
539
+ try {
540
+ return new URL(a).href === new URL(b).href;
541
+ }
542
+ catch {
543
+ return false;
544
+ }
545
+ }
@@ -70,7 +70,7 @@ export class DefaultVaultService {
70
70
  }
71
71
  return { id: vaultId, type: "vault_deleted" };
72
72
  }
73
- createCredential(workspaceId, vaultId, input) {
73
+ createCredential(workspaceId, vaultId, input, internal = {}) {
74
74
  const vault = this.store.retrieveVault(workspaceId, vaultId);
75
75
  if (!vault)
76
76
  throw notFound(`Vault ${vaultId} not found`);
@@ -96,6 +96,9 @@ export class DefaultVaultService {
96
96
  archived_at: null,
97
97
  },
98
98
  token: req.secretValue,
99
+ ...(req.auth.type === "mcp_oauth" && internal.oauthAuthorizationServer !== undefined
100
+ ? { oauthAuthorizationServer: internal.oauthAuthorizationServer }
101
+ : {}),
99
102
  nextRefreshAt: oauthSchedule(new Date(now), req.auth, req.auth.type === "mcp_oauth" && req.auth.refresh !== undefined),
100
103
  }));
101
104
  if (req.auth.type === "mcp_oauth" && req.auth.refresh !== undefined) {
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS vault_credentials (
28
28
  client_id TEXT,
29
29
  scope TEXT,
30
30
  token_endpoint_auth_type TEXT,
31
+ oauth_authorization_server TEXT,
31
32
  expires_at TEXT,
32
33
  auth_version INTEGER NOT NULL DEFAULT 1,
33
34
  refresh_status TEXT,
@@ -95,8 +96,9 @@ export class SqliteVaultStore {
95
96
  this.insertCredentialStmt = this.db.prepare(`INSERT INTO vault_credentials (
96
97
  id, workspace_id, vault_id, type, display_name, metadata, auth_type,
97
98
  mcp_server_url, token_endpoint, client_id, scope, token_endpoint_auth_type,
99
+ oauth_authorization_server,
98
100
  expires_at, auth_version, next_refresh_at, created_at, updated_at, archived_at
99
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
101
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
100
102
  this.retrieveCredentialActiveStmt = this.db.prepare(`SELECT * FROM vault_credentials
101
103
  WHERE workspace_id = ? AND vault_id = ? AND id = ? AND archived_at IS NULL`);
102
104
  this.retrieveCredentialAnyStmt = this.db.prepare(`SELECT * FROM vault_credentials
@@ -249,7 +251,7 @@ export class SqliteVaultStore {
249
251
  this.requireSecrets().put(c.workspace_id, secretName(c.vault_id, c.id), record.token);
250
252
  this.insertCredentialStmt.run(c.id, c.workspace_id, c.vault_id, c.type, c.display_name, JSON.stringify(c.metadata), c.auth.type, c.auth.mcp_server_url, c.auth.type === "mcp_oauth" ? c.auth.refresh?.token_endpoint ?? null : null, c.auth.type === "mcp_oauth" ? c.auth.refresh?.client_id ?? null : null, c.auth.type === "mcp_oauth" ? c.auth.refresh?.scope ?? null : null, c.auth.type === "mcp_oauth"
251
253
  ? c.auth.refresh?.token_endpoint_auth.type ?? null
252
- : null, c.auth.type === "mcp_oauth" ? c.auth.expires_at ?? null : null, c.auth_version, record.nextRefreshAt ?? null, c.created_at, c.updated_at, c.archived_at);
254
+ : null, record.oauthAuthorizationServer ?? null, c.auth.type === "mcp_oauth" ? c.auth.expires_at ?? null : null, c.auth_version, record.nextRefreshAt ?? null, c.created_at, c.updated_at, c.archived_at);
253
255
  return c;
254
256
  });
255
257
  }
@@ -470,6 +472,9 @@ export class SqliteVaultStore {
470
472
  : {
471
473
  refresh: {
472
474
  tokenEndpoint: auth.refresh.token_endpoint,
475
+ ...(row.oauth_authorization_server === null
476
+ ? {}
477
+ : { authorizationServer: row.oauth_authorization_server }),
473
478
  clientId: auth.refresh.client_id,
474
479
  ...(auth.refresh.scope === undefined
475
480
  ? {}
@@ -660,6 +665,7 @@ function ensureVaultCredentialColumns(db) {
660
665
  ["client_id", "TEXT"],
661
666
  ["scope", "TEXT"],
662
667
  ["token_endpoint_auth_type", "TEXT"],
668
+ ["oauth_authorization_server", "TEXT"],
663
669
  ["expires_at", "TEXT"],
664
670
  ["auth_version", "INTEGER NOT NULL DEFAULT 1"],
665
671
  ["refresh_status", "TEXT"],
package/dist/src/main.js CHANGED
@@ -50,7 +50,7 @@ export async function startAppliance(env = process.env, opts = {}) {
50
50
  const resolved = resolveApplianceEnv(env);
51
51
  const port = parseAppliancePort(resolved.OMA_PORT);
52
52
  const host = resolved.OMA_HOST ?? "127.0.0.1";
53
- const plane = createDeploymentControlPlane(resolved, {
53
+ const plane = await createDeploymentControlPlane(resolved, {
54
54
  ...(opts.onboarding === undefined ? {} : { consoleBootstrap: opts.onboarding.bootstrap }),
55
55
  });
56
56
  const { app, stores, authMode } = plane;