@opengeni/core 0.10.0 → 0.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
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": {
@@ -35,14 +35,14 @@
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.29.0",
37
37
  "@opengeni/codex": "^0.2.7",
38
- "@opengeni/config": "^0.6.9",
39
- "@opengeni/contracts": "^0.18.0",
40
- "@opengeni/db": "^0.10.7",
41
- "@opengeni/documents": "^0.2.28",
42
- "@opengeni/events": "^0.3.19",
38
+ "@opengeni/config": "^0.7.0",
39
+ "@opengeni/contracts": "^0.19.0",
40
+ "@opengeni/db": "^0.12.0",
41
+ "@opengeni/documents": "^0.2.30",
42
+ "@opengeni/events": "^0.3.21",
43
43
  "@opengeni/observability": "^0.3.0",
44
- "@opengeni/runtime": "^0.13.0",
45
- "@opengeni/storage": "^0.2.22",
44
+ "@opengeni/runtime": "^0.13.3",
45
+ "@opengeni/storage": "^0.2.24",
46
46
  "hono": "^4.12.18"
47
47
  },
48
48
  "engines": {
@@ -0,0 +1,126 @@
1
+ import {
2
+ NewSessionDraft,
3
+ SaveNewSessionDraftRequest,
4
+ type AccessGrant,
5
+ type NewSessionDraft as NewSessionDraftValue,
6
+ } from "@opengeni/contracts";
7
+ import {
8
+ getNewSessionDraftInTransaction,
9
+ NewSessionDraftAccessError,
10
+ saveNewSessionDraftInTransaction,
11
+ withWorkspaceSubjectRls,
12
+ } from "@opengeni/db";
13
+ import { HTTPException } from "hono/http-exception";
14
+ import type { AppDependencies } from "../dependencies";
15
+ import { settingsWithEnabledCapabilityMcpServers } from "../domain/capabilities";
16
+ import {
17
+ normalizeResources,
18
+ validateFileResources,
19
+ validateGitHubRepositorySelection,
20
+ validateToolRefs,
21
+ } from "../domain/resources";
22
+ import { assertConfiguredModel, assertWorkspaceModelPolicyAllows } from "../domain/sessions";
23
+
24
+ type NewSessionDraftDependencies = Pick<AppDependencies, "settings" | "db" | "objectStorage">;
25
+
26
+ function mapNewSessionDraft(
27
+ row: Awaited<ReturnType<typeof getNewSessionDraftInTransaction>>,
28
+ ): NewSessionDraftValue | null {
29
+ if (!row) return null;
30
+ return NewSessionDraft.parse({
31
+ revision: row.revision,
32
+ text: row.text,
33
+ resources: row.resources,
34
+ tools: row.tools,
35
+ model: row.model,
36
+ reasoningEffort: row.reasoningEffort,
37
+ options: row.sessionOptions,
38
+ updatedAt: row.updatedAt.toISOString(),
39
+ });
40
+ }
41
+
42
+ /** Read the authenticated actor's server-authoritative pre-session composer state. */
43
+ export async function getActorNewSessionDraft(
44
+ deps: Pick<NewSessionDraftDependencies, "settings" | "db">,
45
+ grant: AccessGrant,
46
+ workspaceId: string,
47
+ ): Promise<NewSessionDraftValue> {
48
+ const row = await withWorkspaceSubjectRls(deps.db, workspaceId, grant.subjectId, (scoped) =>
49
+ getNewSessionDraftInTransaction(scoped, {
50
+ workspaceId,
51
+ subjectId: grant.subjectId,
52
+ }),
53
+ );
54
+ return (
55
+ mapNewSessionDraft(row) ?? {
56
+ revision: 0,
57
+ text: "",
58
+ resources: [],
59
+ tools: [],
60
+ model: deps.settings.openaiModel,
61
+ reasoningEffort: deps.settings.openaiReasoningEffort,
62
+ options: {},
63
+ updatedAt: null,
64
+ }
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Validate and save one exact actor-private draft revision. Create-time-only
70
+ * checks (live machine target, rig/variable-set state, and permission
71
+ * delegation) intentionally remain in createSessionForRequest: a recoverable
72
+ * draft may represent incomplete options, while no invalid option can become a
73
+ * session without passing that single canonical create boundary.
74
+ */
75
+ export async function saveActorNewSessionDraft(
76
+ deps: NewSessionDraftDependencies,
77
+ grant: AccessGrant,
78
+ workspaceId: string,
79
+ rawInput: unknown,
80
+ ): Promise<NewSessionDraftValue> {
81
+ const input = SaveNewSessionDraftRequest.parse(rawInput);
82
+ const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
83
+ deps.db,
84
+ workspaceId,
85
+ deps.settings,
86
+ );
87
+ const resources = normalizeResources(input.resources);
88
+ const tools = validateToolRefs(input.tools, runtimeSettings);
89
+ await validateGitHubRepositorySelection(deps.db, workspaceId, resources);
90
+ if (resources.some((resource) => resource.kind === "file") && !deps.objectStorage) {
91
+ throw new HTTPException(503, { message: "object storage is not configured" });
92
+ }
93
+ await validateFileResources(deps.db, workspaceId, resources);
94
+ assertConfiguredModel(deps.settings, input.model);
95
+ await assertWorkspaceModelPolicyAllows(deps.db, deps.settings, workspaceId, input.model);
96
+
97
+ try {
98
+ const saved = await withWorkspaceSubjectRls(deps.db, workspaceId, grant.subjectId, (scoped) =>
99
+ scoped.transaction((tx) =>
100
+ saveNewSessionDraftInTransaction(tx as unknown as typeof scoped, {
101
+ accountId: grant.accountId,
102
+ workspaceId,
103
+ subjectId: grant.subjectId,
104
+ expectedRevision: input.expectedRevision,
105
+ text: input.text,
106
+ resources,
107
+ tools,
108
+ model: input.model,
109
+ reasoningEffort: input.reasoningEffort,
110
+ options: input.options,
111
+ // Only managed people are removed through removeWorkspaceMember().
112
+ // API keys and delegated service actors (for example the first-party
113
+ // worker MCP principal) legitimately have no workspace_memberships
114
+ // row, so they must not be rejected by the human-removal fence.
115
+ requireWorkspaceMembership: grant.subjectId.startsWith("user:"),
116
+ }),
117
+ ),
118
+ );
119
+ return mapNewSessionDraft(saved)!;
120
+ } catch (error) {
121
+ if (error instanceof NewSessionDraftAccessError) {
122
+ throw new HTTPException(403, { message: error.message });
123
+ }
124
+ throw error;
125
+ }
126
+ }
@@ -9,14 +9,16 @@ import type {
9
9
  import {
10
10
  createScheduledTask,
11
11
  deleteScheduledTask,
12
+ getNestedAgentDepthDeploymentPolicy,
12
13
  getRig,
13
14
  getScheduledTask,
15
+ requireWorkspace,
14
16
  updateScheduledTask,
15
17
  type Database,
16
18
  type UpdateScheduledTaskInput,
17
19
  } from "@opengeni/db";
18
20
  import { HTTPException } from "hono/http-exception";
19
- import { requirePermission } from "../access";
21
+ import { hasPermission, requirePermission } from "../access";
20
22
  import type { SessionWorkflowClient } from "../dependencies";
21
23
  import type { ObjectStorageDependency } from "../dependencies";
22
24
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
@@ -199,6 +201,7 @@ export async function validatedScheduledTaskUpdate(input: {
199
201
  settings: input.settings,
200
202
  db: input.db,
201
203
  objectStorage: input.objectStorage,
204
+ grant: input.grant,
202
205
  workspaceId: input.existing.workspaceId,
203
206
  payload: { agentConfig: input.payload.agentConfig },
204
207
  ...(input.toolsProvided !== undefined ? { toolsProvided: input.toolsProvided } : {}),
@@ -318,6 +321,7 @@ async function validateScheduledTaskAgentConfig(input: {
318
321
  settings: Settings;
319
322
  db: Database;
320
323
  objectStorage: ObjectStorageDependency;
324
+ grant: AccessGrant;
321
325
  payload: { agentConfig: ScheduledTaskAgentConfig };
322
326
  workspaceId: string;
323
327
  toolsProvided?: boolean;
@@ -356,6 +360,24 @@ async function validateScheduledTaskAgentConfig(input: {
356
360
  throw new HTTPException(503, { message: "object storage is not configured" });
357
361
  }
358
362
  await validateFileResources(input.db, input.workspaceId, resources);
363
+ const requestedMaxDepth = input.payload.agentConfig.maxNestedAgentDepth;
364
+ if (requestedMaxDepth !== undefined) {
365
+ const workspace = await requireWorkspace(input.db, input.workspaceId);
366
+ const workspaceMaxDepth = workspace.settings.maxNestedAgentDepth;
367
+ const deploymentPolicy = await getNestedAgentDepthDeploymentPolicy(input.db);
368
+ const inheritedMaxDepth =
369
+ typeof workspaceMaxDepth === "number"
370
+ ? workspaceMaxDepth
371
+ : deploymentPolicy.maxNestedAgentDepth;
372
+ if (
373
+ requestedMaxDepth > inheritedMaxDepth &&
374
+ !hasPermission(input.grant.permissions, "workspace:admin")
375
+ ) {
376
+ throw new HTTPException(403, {
377
+ message: `scheduled task maxNestedAgentDepth ${requestedMaxDepth} exceeds inherited limit ${inheritedMaxDepth}; workspace:admin is required to increase it`,
378
+ });
379
+ }
380
+ }
359
381
  return {
360
382
  ...input.payload.agentConfig,
361
383
  ...(model === undefined || model === null ? {} : { model }),
@@ -9,6 +9,7 @@ import {
9
9
  import {
10
10
  CreateSessionRequest,
11
11
  DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
12
+ SessionSpawnDenial,
12
13
  ServiceTurnInitiator,
13
14
  ServiceTurnInitiatorContext,
14
15
  evaluateWorkspaceModelPolicy,
@@ -36,7 +37,7 @@ import {
36
37
  } from "@opengeni/contracts";
37
38
  import {
38
39
  createSession,
39
- createSessionWithIdempotencyKey,
40
+ createSessionWithIdempotencyKeyResult,
40
41
  encryptVariableSetValue,
41
42
  getAnySessionInGroup,
42
43
  getEnrollment,
@@ -47,7 +48,7 @@ import {
47
48
  getSandbox,
48
49
  getSession,
49
50
  SessionIdConflictError,
50
- getSessionByCreateIdempotencyKey,
51
+ getSessionSpawnDenialByIdempotencyKey,
51
52
  getSessionEvent,
52
53
  getWorkspaceControlEvent,
53
54
  getSessionLineage,
@@ -66,6 +67,7 @@ import {
66
67
  type UpdateSessionMcpServerCredentialsInput,
67
68
  QueueCommandConflictError,
68
69
  AgentCommandAuthorityError,
70
+ SessionSpawnDeniedDbError,
69
71
  SessionControlConflictError,
70
72
  type SessionCommandActor,
71
73
  } from "@opengeni/db";
@@ -105,6 +107,34 @@ const maxSessionMcpCredentialHeaderValueLength = 4096;
105
107
  // RFC 9110 field-name token characters.
106
108
  const sessionMcpCredentialHeaderName = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
107
109
 
110
+ /** Transport-neutral typed denial raised only after its audit row committed. */
111
+ export class SessionSpawnDeniedError extends Error {
112
+ readonly denial: SessionSpawnDenial;
113
+
114
+ constructor(denial: SessionSpawnDenial) {
115
+ super(sessionSpawnDeniedMessage(denial));
116
+ this.name = "SessionSpawnDeniedError";
117
+ this.denial = denial;
118
+ }
119
+ }
120
+
121
+ function sessionSpawnDeniedMessage(denial: SessionSpawnDenial): string {
122
+ if (denial.code === "nested_agent_depth_override_forbidden") {
123
+ return `requested nested-agent depth limit ${denial.requestedMaxNestedAgentDepthOverride ?? "unknown"} exceeds inherited limit ${denial.effectiveMaxNestedAgentDepth}; workspace:admin is required to increase it`;
124
+ }
125
+ return `nested-agent depth ${denial.attemptedDepth} exceeds effective limit ${denial.effectiveMaxNestedAgentDepth} (current parent depth ${denial.currentDepth})`;
126
+ }
127
+
128
+ export function sessionSpawnDenialEnvelope(error: SessionSpawnDeniedError) {
129
+ return {
130
+ error: {
131
+ code: error.denial.code,
132
+ message: error.message,
133
+ details: { denial: error.denial },
134
+ },
135
+ } as const;
136
+ }
137
+
108
138
  type ValidatedSessionMcpServers = {
109
139
  runtimeServers: Settings["mcpServers"];
110
140
  dbServers: CreateSessionMcpServerInput[];
@@ -513,36 +543,27 @@ export async function createAndStartSession(input: {
513
543
  // `workingDir` (optional) is the path/cwd base the chosen machine runs under,
514
544
  // seeded alongside the pointer through the epoch-fenced CAS.
515
545
  seedTargetSandbox?: { sandboxId: string; settings: Settings; workingDir?: string | null } | null;
546
+ // Exact actor-private pre-session draft represented by this create. The
547
+ // initializer consumes it only after the first durable runnable unit commits.
548
+ consumeNewSessionDraft?: { subjectId: string; expectedRevision: number } | null;
549
+ // A child may lower its inherited nested-agent depth limit freely; increases
550
+ // are authorized by the caller's workspace:admin grant and checked again by
551
+ // the database admission transaction.
552
+ maxNestedAgentDepthOverride?: number | null;
553
+ allowNestedAgentDepthIncrease?: boolean;
554
+ subjectId?: string | null;
516
555
  }): Promise<CreateSessionResponse> {
517
556
  const sessionMetadata = {
518
557
  ...input.metadata,
519
558
  model: input.model,
520
559
  reasoningEffort: input.reasoningEffort,
521
560
  };
522
- // Fast path with a key: return a session already created under this key
523
- // (the sequential retry / double-submit case) without inserting again.
561
+ // Keyed creation is intentionally handled only by the database admission
562
+ // transaction below. Its workspace/key lock replays either the successful
563
+ // session or the committed denial atomically; an application-side lookup
564
+ // cannot serialize those two source tables against an older writer.
524
565
  if (input.createIdempotencyKey) {
525
- const existing = await getSessionByCreateIdempotencyKey(
526
- input.db,
527
- input.workspaceId,
528
- input.createIdempotencyKey,
529
- );
530
- if (existing) {
531
- if (input.requestedSessionId && existing.id !== input.requestedSessionId) {
532
- throw new SessionIdConflictError(input.requestedSessionId);
533
- }
534
- return await finishStartSession(
535
- existing.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
536
- existing,
537
- );
538
- }
539
- // No prior session: insert under the key, racing concurrent creates. The
540
- // partial unique index lets exactly one insert win; a loser gets back the
541
- // winner's row with created=false. Both callers may enter the idempotent
542
- // initializer; exactly one creates the first events/turn. Each retry
543
- // advances the coalesced wake revision so an in-flight stale delivery can
544
- // never acknowledge work committed by the other caller.
545
- const { session: keyed, created } = await createSessionWithIdempotencyKey(input.db, {
566
+ const keyedResult = await createSessionWithIdempotencyKeyResult(input.db, {
546
567
  ...(input.requestedSessionId ? { requestedSessionId: input.requestedSessionId } : {}),
547
568
  accountId: input.accountId,
548
569
  workspaceId: input.workspaceId,
@@ -567,7 +588,14 @@ export async function createAndStartSession(input: {
567
588
  sandboxGroupId: input.sandboxGroupId ?? null,
568
589
  ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
569
590
  mcpServers: input.mcpServers ?? [],
591
+ maxNestedAgentDepthOverride: input.maxNestedAgentDepthOverride ?? null,
592
+ allowNestedAgentDepthIncrease: input.allowNestedAgentDepthIncrease ?? false,
593
+ subjectId: input.subjectId ?? null,
570
594
  });
595
+ if (keyedResult.denied) {
596
+ throw new SessionSpawnDeniedError(SessionSpawnDenial.parse(keyedResult.denial));
597
+ }
598
+ const { session: keyed, created } = keyedResult;
571
599
  if (!created) {
572
600
  return await finishStartSession(
573
601
  keyed.temporalWorkflowId ? { ...input, seedTargetSandbox: null } : input,
@@ -576,31 +604,42 @@ export async function createAndStartSession(input: {
576
604
  }
577
605
  return await finishStartSession(input, keyed);
578
606
  }
579
- const session = await createSession(input.db, {
580
- ...(input.requestedSessionId ? { requestedSessionId: input.requestedSessionId } : {}),
581
- accountId: input.accountId,
582
- workspaceId: input.workspaceId,
583
- initialMessage: input.initialMessage,
584
- initialTurnInstructions: input.turnInstructions ?? null,
585
- resources: input.resources,
586
- tools: input.tools,
587
- ...(input.toolPolicy ? { toolPolicy: input.toolPolicy } : {}),
588
- metadata: sessionMetadata,
589
- ...(input.createdBy ? { createdBy: input.createdBy } : {}),
590
- ...(input.createdByContext ? { createdByContext: input.createdByContext } : {}),
591
- createdByActor: input.createdByActor ?? null,
592
- model: input.model,
593
- sandboxBackend: input.sandboxBackend,
594
- variableSetId: input.variableSet?.id ?? null,
595
- rigId: input.rigId ?? null,
596
- rigVersionId: input.rigVersionId ?? null,
597
- firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
598
- instructions: input.instructions ?? null,
599
- parentSessionId: input.parentSessionId ?? null,
600
- sandboxGroupId: input.sandboxGroupId ?? null,
601
- ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
602
- mcpServers: input.mcpServers ?? [],
603
- });
607
+ let session: Session;
608
+ try {
609
+ session = await createSession(input.db, {
610
+ ...(input.requestedSessionId ? { requestedSessionId: input.requestedSessionId } : {}),
611
+ accountId: input.accountId,
612
+ workspaceId: input.workspaceId,
613
+ initialMessage: input.initialMessage,
614
+ initialTurnInstructions: input.turnInstructions ?? null,
615
+ resources: input.resources,
616
+ tools: input.tools,
617
+ ...(input.toolPolicy ? { toolPolicy: input.toolPolicy } : {}),
618
+ metadata: sessionMetadata,
619
+ ...(input.createdBy ? { createdBy: input.createdBy } : {}),
620
+ ...(input.createdByContext ? { createdByContext: input.createdByContext } : {}),
621
+ createdByActor: input.createdByActor ?? null,
622
+ model: input.model,
623
+ sandboxBackend: input.sandboxBackend,
624
+ variableSetId: input.variableSet?.id ?? null,
625
+ rigId: input.rigId ?? null,
626
+ rigVersionId: input.rigVersionId ?? null,
627
+ firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
628
+ instructions: input.instructions ?? null,
629
+ parentSessionId: input.parentSessionId ?? null,
630
+ sandboxGroupId: input.sandboxGroupId ?? null,
631
+ ...(input.sandboxOs ? { sandboxOs: input.sandboxOs } : {}),
632
+ mcpServers: input.mcpServers ?? [],
633
+ maxNestedAgentDepthOverride: input.maxNestedAgentDepthOverride ?? null,
634
+ allowNestedAgentDepthIncrease: input.allowNestedAgentDepthIncrease ?? false,
635
+ subjectId: input.subjectId ?? null,
636
+ });
637
+ } catch (error) {
638
+ if (error instanceof SessionSpawnDeniedDbError) {
639
+ throw new SessionSpawnDeniedError(SessionSpawnDenial.parse(error.denial));
640
+ }
641
+ throw error;
642
+ }
604
643
  return await finishStartSession(input, session);
605
644
  }
606
645
 
@@ -633,6 +672,7 @@ async function finishStartSession(
633
672
  settings: Settings;
634
673
  workingDir?: string | null;
635
674
  } | null;
675
+ consumeNewSessionDraft?: { subjectId: string; expectedRevision: number } | null;
636
676
  },
637
677
  session: Session,
638
678
  ): Promise<CreateSessionResponse> {
@@ -693,6 +733,7 @@ async function finishStartSession(
693
733
  : {}),
694
734
  }
695
735
  : null,
736
+ consumeNewSessionDraft: input.consumeNewSessionDraft ?? null,
696
737
  });
697
738
  await publishDurableSessionEvents(input.bus, session.workspaceId, session.id, started.events);
698
739
  if (started.workflowWakeRevision !== null) {
@@ -980,6 +1021,20 @@ export async function createSessionForRequest(
980
1021
  ): Promise<Session> {
981
1022
  const { settings, db, bus, workflowClient, objectStorage } = deps;
982
1023
  const payload = CreateSessionRequest.parse(rawPayload);
1024
+ // A committed keyed denial is the idempotent outcome even if mutable
1025
+ // resources, policy, authorization, or budget have changed since the first
1026
+ // attempt. Replay it before any of those checks, just as a keyed successful
1027
+ // session is returned rather than recreated later in createAndStartSession.
1028
+ if (payload.idempotencyKey) {
1029
+ const denial = await getSessionSpawnDenialByIdempotencyKey(
1030
+ db,
1031
+ workspaceId,
1032
+ payload.idempotencyKey,
1033
+ );
1034
+ if (denial) {
1035
+ throw new SessionSpawnDeniedError(SessionSpawnDenial.parse(denial));
1036
+ }
1037
+ }
983
1038
  // Parent linkage and execution-context inheritance come ONLY from the
984
1039
  // worker-signed sessionId claim. A caller cannot nominate a parent in the
985
1040
  // payload, so inheriting an existing repository/tool/credential snapshot does
@@ -1469,6 +1524,9 @@ export async function createSessionForRequest(
1469
1524
  sessionMcpServers: sessionMcpServers.metadata,
1470
1525
  parentSessionId,
1471
1526
  createIdempotencyKey: payload.idempotencyKey ?? null,
1527
+ maxNestedAgentDepthOverride: payload.maxNestedAgentDepth ?? null,
1528
+ allowNestedAgentDepthIncrease: hasPermission(grant.permissions, "workspace:admin"),
1529
+ subjectId: grant.subjectId,
1472
1530
  // Create-time machine targeting (A-2a): when a target sandbox is named, the
1473
1531
  // active-sandbox pointer is seeded race-free inside createAndStartSession
1474
1532
  // (after the row exists, before the first turn dispatches). Validation
@@ -1476,6 +1534,13 @@ export async function createSessionForRequest(
1476
1534
  seedTargetSandbox: payload.targetSandboxId
1477
1535
  ? { sandboxId: payload.targetSandboxId, settings, workingDir: payload.workingDir ?? null }
1478
1536
  : null,
1537
+ consumeNewSessionDraft:
1538
+ payload.expectedNewSessionDraftRevision !== undefined
1539
+ ? {
1540
+ subjectId: grant.subjectId,
1541
+ expectedRevision: payload.expectedNewSessionDraftRevision,
1542
+ }
1543
+ : null,
1479
1544
  });
1480
1545
  } catch (error) {
1481
1546
  if (error instanceof AgentCommandAuthorityError) {
package/src/index.ts CHANGED
@@ -61,4 +61,5 @@ export * from "./domain/session-tool-policy";
61
61
  export * from "./domain/scheduled-tasks";
62
62
  export * from "./domain/sessions";
63
63
  export * from "./domain/workspace-members";
64
+ export * from "./application/new-session-drafts";
64
65
  export * from "./application/session-commands";