@opengeni/core 0.2.2 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -37,14 +37,14 @@
37
37
  "dependencies": {
38
38
  "@modelcontextprotocol/sdk": "^1.29.0",
39
39
  "@opengeni/codex": "^0.2.1",
40
- "@opengeni/config": "^0.2.2",
41
- "@opengeni/contracts": "^0.4.0",
42
- "@opengeni/db": "^0.2.2",
43
- "@opengeni/documents": "^0.2.2",
44
- "@opengeni/events": "^0.2.2",
40
+ "@opengeni/config": "^0.2.4",
41
+ "@opengeni/contracts": "^0.6.0",
42
+ "@opengeni/db": "^0.4.0",
43
+ "@opengeni/documents": "^0.2.4",
44
+ "@opengeni/events": "^0.2.4",
45
45
  "@opengeni/observability": "^0.2.1",
46
- "@opengeni/runtime": "^0.2.2",
47
- "@opengeni/storage": "^0.2.2",
46
+ "@opengeni/runtime": "^0.3.0",
47
+ "@opengeni/storage": "^0.2.4",
48
48
  "hono": "^4.12.18"
49
49
  },
50
50
  "devDependencies": {
@@ -10,6 +10,9 @@ import {
10
10
  type ResourceRef,
11
11
  type Session,
12
12
  type SessionEvent,
13
+ type SessionMcpCredentialUpdateInput,
14
+ type SessionMcpServerInput,
15
+ type SessionMcpServerMetadata,
13
16
  type SessionTurn,
14
17
  type ToolRef,
15
18
  } from "@opengeni/contracts";
@@ -19,6 +22,7 @@ import {
19
22
  createSessionGoal,
20
23
  createSessionWithIdempotencyKey,
21
24
  enqueueSessionTurn,
25
+ encryptEnvironmentValue,
22
26
  getAnySessionInGroup,
23
27
  getEnrollment,
24
28
  listDistinctEnvironmentIdsInGroup,
@@ -29,16 +33,18 @@ import {
29
33
  requireSession,
30
34
  setTemporalWorkflowId,
31
35
  updateSessionTitle as updateSessionTitleRow,
36
+ type CreateSessionMcpServerInput,
32
37
  type Database,
38
+ type UpdateSessionMcpServerCredentialsInput,
33
39
  } from "@opengeni/db";
34
40
  import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
35
41
  import { HTTPException } from "hono/http-exception";
36
- import { hasPermission } from "../access";
42
+ import { hasPermission, requirePermission } from "../access";
37
43
  import { recordWorkspaceUsage, requireLimit } from "../billing/limits";
38
44
  import type { ApiRouteDeps, SessionWorkflowClient } from "../dependencies";
39
45
  import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
40
46
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
41
- import { validateEnvironmentAttachment } from "./environments";
47
+ import { requireEnvironmentEncryption, validateEnvironmentAttachment } from "./environments";
42
48
  import {
43
49
  mergeResourceRefs,
44
50
  mergeToolRefs,
@@ -49,6 +55,166 @@ import {
49
55
  withDefaultEnabledCapabilityMcpTools,
50
56
  } from "./resources";
51
57
 
58
+ const reservedSessionMcpServerIds = new Set(["opengeni", "files", "docs", "codex_apps"]);
59
+ const maxSessionMcpCredentialHeaders = 16;
60
+ const maxSessionMcpCredentialHeaderValueLength = 4096;
61
+ // RFC 9110 field-name token characters.
62
+ const sessionMcpCredentialHeaderName = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
63
+
64
+ type ValidatedSessionMcpServers = {
65
+ runtimeServers: Settings["mcpServers"];
66
+ dbServers: CreateSessionMcpServerInput[];
67
+ metadata: SessionMcpServerMetadata[];
68
+ };
69
+
70
+ function normalizedSessionMcpCredentialHeaders(headers: Record<string, string> | undefined): Record<string, string> {
71
+ if (!headers) {
72
+ return {};
73
+ }
74
+ const entries = Object.entries(headers).map(([name, value]) => [name.trim(), value] as const).filter(([name]) => name.length > 0);
75
+ if (entries.length > maxSessionMcpCredentialHeaders) {
76
+ throw new HTTPException(422, { message: `a session MCP server supports at most ${maxSessionMcpCredentialHeaders} credential headers` });
77
+ }
78
+ const seen = new Set<string>();
79
+ for (const [name, value] of entries) {
80
+ if (!sessionMcpCredentialHeaderName.test(name)) {
81
+ throw new HTTPException(422, { message: `invalid credential header name: ${name}` });
82
+ }
83
+ const lower = name.toLowerCase();
84
+ if (seen.has(lower)) {
85
+ throw new HTTPException(422, { message: `duplicate credential header name: ${name}` });
86
+ }
87
+ seen.add(lower);
88
+ if (value.length === 0 || value.length > maxSessionMcpCredentialHeaderValueLength) {
89
+ throw new HTTPException(422, { message: `credential header ${name} must be 1-${maxSessionMcpCredentialHeaderValueLength} characters` });
90
+ }
91
+ // RFC 9110 §5.5: field values are HTAB / printable characters.
92
+ // eslint-disable-next-line no-control-regex
93
+ if (/[\u0000-\u0008\u000A-\u001F\u007F]/.test(value)) {
94
+ throw new HTTPException(422, { message: `credential header ${name} contains forbidden control characters` });
95
+ }
96
+ }
97
+ return Object.fromEntries(entries);
98
+ }
99
+
100
+ function mcpServerConfigFromInput(server: SessionMcpServerInput): Settings["mcpServers"][number] {
101
+ return {
102
+ id: server.id,
103
+ ...(server.name ? { name: server.name } : {}),
104
+ url: server.url,
105
+ ...(server.allowedTools ? { allowedTools: server.allowedTools } : {}),
106
+ ...(server.timeoutMs ? { timeoutMs: server.timeoutMs } : {}),
107
+ cacheToolsList: server.cacheToolsList ?? false,
108
+ };
109
+ }
110
+
111
+ function mcpServerConfigFromMetadata(server: SessionMcpServerMetadata): Settings["mcpServers"][number] {
112
+ return {
113
+ id: server.id,
114
+ ...(server.name ? { name: server.name } : {}),
115
+ url: server.url,
116
+ cacheToolsList: false,
117
+ };
118
+ }
119
+
120
+ function settingsWithSessionMcpServerConfigs(settings: Settings, servers: Settings["mcpServers"]): Settings {
121
+ if (servers.length === 0) {
122
+ return settings;
123
+ }
124
+ const sessionIds = new Set(servers.map((server) => server.id));
125
+ return {
126
+ ...settings,
127
+ mcpServers: [
128
+ ...settings.mcpServers.filter((server) => !sessionIds.has(server.id)),
129
+ ...servers,
130
+ ],
131
+ };
132
+ }
133
+
134
+ export function settingsWithSessionMcpServerMetadata(settings: Settings, servers: SessionMcpServerMetadata[]): Settings {
135
+ return settingsWithSessionMcpServerConfigs(settings, servers.map(mcpServerConfigFromMetadata));
136
+ }
137
+
138
+ function validateSessionMcpServersForCreate(
139
+ settings: Settings,
140
+ grant: AccessGrant,
141
+ servers: SessionMcpServerInput[],
142
+ ): ValidatedSessionMcpServers {
143
+ if (servers.length === 0) {
144
+ return { runtimeServers: [], dbServers: [], metadata: [] };
145
+ }
146
+ requirePermission(grant, "mcp_servers:attach");
147
+ const encryptionKey = requireEnvironmentEncryption(settings);
148
+ const existingIds = new Set(settings.mcpServers.map((server) => server.id));
149
+ const seenIds = new Set<string>();
150
+ const runtimeServers: Settings["mcpServers"] = [];
151
+ const dbServers: CreateSessionMcpServerInput[] = [];
152
+ const metadata: SessionMcpServerMetadata[] = [];
153
+ for (const server of servers) {
154
+ if (seenIds.has(server.id)) {
155
+ throw new HTTPException(422, { message: `duplicate session MCP server id: ${server.id}` });
156
+ }
157
+ seenIds.add(server.id);
158
+ if (reservedSessionMcpServerIds.has(server.id) || existingIds.has(server.id)) {
159
+ throw new HTTPException(422, { message: `MCP server id already exists: ${server.id}` });
160
+ }
161
+ const headers = normalizedSessionMcpCredentialHeaders(server.headers);
162
+ const headersEncrypted = Object.fromEntries(
163
+ Object.entries(headers).map(([name, value]) => [name, encryptEnvironmentValue(encryptionKey, value)]),
164
+ );
165
+ runtimeServers.push(mcpServerConfigFromInput(server));
166
+ dbServers.push({
167
+ id: server.id,
168
+ name: server.name ?? null,
169
+ url: server.url,
170
+ allowedTools: server.allowedTools ?? null,
171
+ timeoutMs: server.timeoutMs ?? null,
172
+ cacheToolsList: server.cacheToolsList ?? false,
173
+ headersEncrypted,
174
+ });
175
+ metadata.push({
176
+ id: server.id,
177
+ name: server.name ?? null,
178
+ url: server.url,
179
+ headerNames: Object.keys(headersEncrypted).sort(),
180
+ credentialVersion: 1,
181
+ });
182
+ }
183
+ return { runtimeServers, dbServers, metadata };
184
+ }
185
+
186
+ function validateSessionMcpCredentialUpdates(input: {
187
+ settings: Settings;
188
+ grant: AccessGrant;
189
+ session: Session;
190
+ updates: SessionMcpCredentialUpdateInput[];
191
+ }): UpdateSessionMcpServerCredentialsInput[] {
192
+ if (input.updates.length === 0) {
193
+ return [];
194
+ }
195
+ requirePermission(input.grant, "mcp_servers:attach");
196
+ const encryptionKey = requireEnvironmentEncryption(input.settings);
197
+ const knownIds = new Set(input.session.mcpServers.map((server) => server.id));
198
+ const seenIds = new Set<string>();
199
+ const encryptedUpdates = input.updates.map((update) => {
200
+ if (seenIds.has(update.id)) {
201
+ throw new HTTPException(422, { message: `duplicate session MCP credential update id: ${update.id}` });
202
+ }
203
+ seenIds.add(update.id);
204
+ if (!knownIds.has(update.id)) {
205
+ throw new HTTPException(422, { message: `unknown session MCP server id: ${update.id}` });
206
+ }
207
+ const headers = normalizedSessionMcpCredentialHeaders(update.headers);
208
+ return {
209
+ id: update.id,
210
+ headersEncrypted: Object.fromEntries(
211
+ Object.entries(headers).map(([name, value]) => [name, encryptEnvironmentValue(encryptionKey, value)]),
212
+ ),
213
+ };
214
+ });
215
+ return encryptedUpdates;
216
+ }
217
+
52
218
  export async function createAndStartSession(input: {
53
219
  db: Database;
54
220
  bus: EventBus;
@@ -66,8 +232,17 @@ export async function createAndStartSession(input: {
66
232
  // Names/ids only; the session.created payload never carries variable values.
67
233
  environment?: { id: string; name: string } | null;
68
234
  goal?: GoalSpec | null;
235
+ // Per-session agent persona/system instructions (org-visible metadata, not a
236
+ // secret). Persisted on the session row and composed system-level AFTER the
237
+ // workspace agentInstructions at turn time; never emitted as a timeline event.
238
+ // Null/omitted ⇒ the session carries none.
239
+ instructions?: string | null;
69
240
  // Validated against the creating grant before this is called.
70
241
  firstPartyMcpPermissions?: Permission[] | null;
242
+ // Encrypted DB rows plus matching safe metadata for create-time per-session
243
+ // MCP servers. Metadata is the only shape emitted in events/responses.
244
+ mcpServers?: CreateSessionMcpServerInput[];
245
+ sessionMcpServers?: SessionMcpServerMetadata[];
71
246
  // The manager session spawning this worker (a worker-signed sessionId claim
72
247
  // on the creating grant); null for direct API creates and scheduled runs.
73
248
  // When set, the worker's terminal-for-now transitions wake this parent.
@@ -123,10 +298,12 @@ export async function createAndStartSession(input: {
123
298
  sandboxBackend: input.sandboxBackend,
124
299
  environmentId: input.environment?.id ?? null,
125
300
  firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
301
+ instructions: input.instructions ?? null,
126
302
  parentSessionId: input.parentSessionId ?? null,
127
303
  createIdempotencyKey: input.createIdempotencyKey,
128
304
  sandboxGroupId: input.sandboxGroupId ?? null,
129
305
  ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
306
+ mcpServers: input.mcpServers ?? [],
130
307
  });
131
308
  if (!created) {
132
309
  return keyed;
@@ -144,9 +321,11 @@ export async function createAndStartSession(input: {
144
321
  sandboxBackend: input.sandboxBackend,
145
322
  environmentId: input.environment?.id ?? null,
146
323
  firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
324
+ instructions: input.instructions ?? null,
147
325
  parentSessionId: input.parentSessionId ?? null,
148
326
  sandboxGroupId: input.sandboxGroupId ?? null,
149
327
  ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
328
+ mcpServers: input.mcpServers ?? [],
150
329
  });
151
330
  return await finishStartSession(input, session);
152
331
  }
@@ -171,6 +350,7 @@ async function finishStartSession(input: {
171
350
  sandboxBackend: Settings["sandboxBackend"];
172
351
  environment?: { id: string; name: string } | null;
173
352
  goal?: GoalSpec | null;
353
+ sessionMcpServers?: SessionMcpServerMetadata[];
174
354
  seedTargetSandbox?: { sandboxId: string; settings: Settings; workingDir?: string | null } | null;
175
355
  }, session: Session): Promise<Session> {
176
356
  // The goal row is durable session state; the workflow picks it up from the
@@ -197,6 +377,7 @@ async function finishStartSession(input: {
197
377
  payload: {
198
378
  status: "queued",
199
379
  ...(input.environment ? { environmentId: input.environment.id, environmentName: input.environment.name } : {}),
380
+ ...(input.sessionMcpServers?.length ? { mcpServers: input.sessionMcpServers } : {}),
200
381
  },
201
382
  },
202
383
  ...(goal ? [{
@@ -355,6 +536,7 @@ export async function postUserMessageTurn(input: {
355
536
  model?: string | null;
356
537
  reasoningEffort?: Settings["openaiReasoningEffort"] | null;
357
538
  clientEventId?: string;
539
+ mcpCredentialUpdates?: UpdateSessionMcpServerCredentialsInput[];
358
540
  }): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
359
541
  const { db, bus, workflowClient, settings, accountId, workspaceId, sessionId } = input;
360
542
  const requestedModel = input.model ?? null;
@@ -362,7 +544,7 @@ export async function postUserMessageTurn(input: {
362
544
  // Reject an explicit per-message model the host does not expose; an omitted
363
545
  // model inherits the session's model downstream (always a configured id).
364
546
  assertConfiguredModel(settings, requestedModel);
365
- const appended = await appendSessionEventsWithLockedSessionUpdate(db, workspaceId, sessionId, (lockedSession) => {
547
+ const appended = await appendSessionEventsWithLockedSessionUpdate(db, workspaceId, sessionId, async (lockedSession, lockedUpdate) => {
366
548
  // Cancelled is the one terminal state: an explicit user act. A FAILED
367
549
  // session stays revivable by talking to it — conversation truth lives in
368
550
  // session_history_items, so a failed turn does not invalidate history,
@@ -373,6 +555,12 @@ export async function postUserMessageTurn(input: {
373
555
  if (lockedSession.status === "cancelled") {
374
556
  throw new HTTPException(409, { message: `session is ${lockedSession.status}; cannot accept a new user message` });
375
557
  }
558
+ const mcpCredentialUpdates = input.mcpCredentialUpdates?.length
559
+ ? await lockedUpdate.updateSessionMcpServerCredentials(input.mcpCredentialUpdates)
560
+ : { servers: [], missingIds: [] };
561
+ if (mcpCredentialUpdates.missingIds.length > 0) {
562
+ throw new HTTPException(422, { message: `unknown session MCP server id: ${mcpCredentialUpdates.missingIds[0]}` });
563
+ }
376
564
  const nextResources = mergeResourceRefs(lockedSession.resources, input.resources);
377
565
  const nextTools = mergeToolRefs(lockedSession.tools, input.tools);
378
566
  const shouldQueueSession = lockedSession.status === "idle" || lockedSession.status === "failed";
@@ -386,6 +574,7 @@ export async function postUserMessageTurn(input: {
386
574
  ...(input.tools.length ? { tools: input.tools } : {}),
387
575
  ...(requestedModel ? { model: requestedModel } : {}),
388
576
  ...(requestedReasoningEffort ? { reasoningEffort: requestedReasoningEffort } : {}),
577
+ ...(mcpCredentialUpdates.servers.length ? { mcpCredentialUpdates: mcpCredentialUpdates.servers } : {}),
389
578
  },
390
579
  ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
391
580
  },
@@ -446,12 +635,14 @@ export async function createSessionForRequest(
446
635
  ): Promise<Session> {
447
636
  const { settings, db, bus, workflowClient, objectStorage } = deps;
448
637
  const payload = CreateSessionRequest.parse(rawPayload);
449
- const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
638
+ const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
639
+ const sessionMcpServers = validateSessionMcpServersForCreate(capabilityRuntimeSettings, grant, payload.mcpServers);
640
+ const runtimeSettings = settingsWithSessionMcpServerConfigs(capabilityRuntimeSettings, sessionMcpServers.runtimeServers);
450
641
  const resources = normalizeResources(payload.resources);
451
642
  const requestedTools = validateToolRefs(payload.tools, runtimeSettings);
452
643
  const defaultedTools = hasOwnProperty(rawPayload, "tools")
453
644
  ? requestedTools
454
- : withDefaultEnabledCapabilityMcpTools(requestedTools, settings, runtimeSettings);
645
+ : withDefaultEnabledCapabilityMcpTools(requestedTools, settings, capabilityRuntimeSettings);
455
646
  // The first-party MCP server is attached to EVERY session. It hosts the
456
647
  // session's own metadata tool (set_session_title) + goal tools, and — only
457
648
  // when the grant carries the permission — the orchestration/environment/
@@ -671,7 +862,13 @@ export async function createSessionForRequest(
671
862
  metadata: payload.metadata,
672
863
  environment: environment ? { id: environment.id, name: environment.name } : null,
673
864
  goal: payload.goal ?? null,
865
+ // Per-session persona instructions (already trimmed/validated by the
866
+ // contracts schema). Persisted on the row; composed system-level at turn
867
+ // time. Not surfaced as an event.
868
+ instructions: payload.instructions ?? null,
674
869
  firstPartyMcpPermissions,
870
+ mcpServers: sessionMcpServers.dbServers,
871
+ sessionMcpServers: sessionMcpServers.metadata,
675
872
  parentSessionId,
676
873
  createIdempotencyKey: payload.idempotencyKey ?? null,
677
874
  // Create-time machine targeting (A-2a): when a target sandbox is named, the
@@ -716,19 +913,21 @@ export async function acceptSessionUserMessage(
716
913
  model?: string | null;
717
914
  reasoningEffort?: ReasoningEffort | null;
718
915
  clientEventId?: string;
916
+ mcpCredentialUpdates?: SessionMcpCredentialUpdateInput[];
719
917
  },
720
918
  ): Promise<{ accepted: SessionEvent; turn: SessionTurn }> {
721
919
  const { settings, db, bus, workflowClient, objectStorage } = deps;
722
- const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
723
- const requestedResources = normalizeResources(input.resources ?? []);
724
- const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
725
- const requestedTools = input.toolsProvided
726
- ? validatedTools
727
- : withDefaultEnabledCapabilityMcpTools(validatedTools, settings, runtimeSettings);
920
+ const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings);
728
921
  // Hoisted above requireLimit so the codex-billed predicate can resolve the
729
922
  // turn's effective model (a follow-up turn inherits the session's model). A
730
923
  // pure read with no side effects.
731
924
  const existingSession = await requireSession(db, workspaceId, sessionId);
925
+ const runtimeSettings = settingsWithSessionMcpServerMetadata(capabilityRuntimeSettings, existingSession.mcpServers);
926
+ const requestedResources = normalizeResources(input.resources ?? []);
927
+ const validatedTools = validateToolRefs(input.tools ?? [], runtimeSettings);
928
+ const requestedTools = input.toolsProvided
929
+ ? validatedTools
930
+ : withDefaultEnabledCapabilityMcpTools(validatedTools, settings, capabilityRuntimeSettings);
732
931
  await requireLimit(deps, {
733
932
  accountId: grant.accountId,
734
933
  workspaceId,
@@ -741,6 +940,12 @@ export async function acceptSessionUserMessage(
741
940
  }
742
941
  await validateFileResources(db, workspaceId, requestedResources);
743
942
  await validateGitHubRepositorySelection(db, workspaceId, [...existingSession.resources, ...requestedResources]);
943
+ const mcpCredentialUpdates = validateSessionMcpCredentialUpdates({
944
+ settings,
945
+ grant,
946
+ session: existingSession,
947
+ updates: input.mcpCredentialUpdates ?? [],
948
+ });
744
949
  const { accepted, turn } = await postUserMessageTurn({
745
950
  db,
746
951
  bus,
@@ -754,6 +959,7 @@ export async function acceptSessionUserMessage(
754
959
  tools: requestedTools,
755
960
  model: input.model ?? null,
756
961
  reasoningEffort: input.reasoningEffort ?? null,
962
+ mcpCredentialUpdates,
757
963
  ...(input.clientEventId ? { clientEventId: input.clientEventId } : {}),
758
964
  });
759
965
  await recordWorkspaceUsage(deps, {