@opengeni/core 0.11.2 → 0.12.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/dist/index.d.ts +61 -9
- package/dist/index.js +496 -129
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/application/new-session-drafts.ts +120 -4
- package/src/dependencies.ts +2 -0
- package/src/domain/resources.ts +9 -0
- package/src/domain/scheduled-tasks.ts +31 -1
- package/src/domain/session-tool-policy.ts +25 -13
- package/src/domain/sessions.ts +207 -0
- package/src/domain/slack-bot.ts +130 -0
- package/src/index.ts +1 -0
- package/src/sandbox/routing.ts +292 -225
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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": {
|
|
@@ -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.7.
|
|
39
|
-
"@opengeni/contracts": "^0.
|
|
40
|
-
"@opengeni/db": "^0.
|
|
41
|
-
"@opengeni/documents": "^0.2.
|
|
42
|
-
"@opengeni/events": "^0.3.
|
|
38
|
+
"@opengeni/config": "^0.7.7",
|
|
39
|
+
"@opengeni/contracts": "^0.20.0",
|
|
40
|
+
"@opengeni/db": "^0.13.0",
|
|
41
|
+
"@opengeni/documents": "^0.2.37",
|
|
42
|
+
"@opengeni/events": "^0.3.28",
|
|
43
43
|
"@opengeni/observability": "^0.3.0",
|
|
44
|
-
"@opengeni/runtime": "^0.13.
|
|
45
|
-
"@opengeni/storage": "^0.2.
|
|
44
|
+
"@opengeni/runtime": "^0.13.11",
|
|
45
|
+
"@opengeni/storage": "^0.2.31",
|
|
46
46
|
"hono": "^4.12.18"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
|
@@ -6,7 +6,14 @@ import {
|
|
|
6
6
|
} from "@opengeni/contracts";
|
|
7
7
|
import {
|
|
8
8
|
getNewSessionDraftInTransaction,
|
|
9
|
+
getEnrollment,
|
|
10
|
+
getRig,
|
|
11
|
+
getSandbox,
|
|
12
|
+
getVariableSet,
|
|
9
13
|
NewSessionDraftAccessError,
|
|
14
|
+
newSessionDraftToolsProvided,
|
|
15
|
+
publicNewSessionDraftOptions,
|
|
16
|
+
requireFile,
|
|
10
17
|
saveNewSessionDraftInTransaction,
|
|
11
18
|
withWorkspaceSubjectRls,
|
|
12
19
|
} from "@opengeni/db";
|
|
@@ -14,15 +21,21 @@ import { HTTPException } from "hono/http-exception";
|
|
|
14
21
|
import type { AppDependencies } from "../dependencies";
|
|
15
22
|
import { settingsWithEnabledCapabilityMcpServers } from "../domain/capabilities";
|
|
16
23
|
import {
|
|
24
|
+
isAuthoritativeGitHubRepositorySelectionError,
|
|
17
25
|
normalizeResources,
|
|
18
26
|
validateFileResources,
|
|
19
27
|
validateGitHubRepositorySelection,
|
|
20
28
|
validateToolRefs,
|
|
21
29
|
} from "../domain/resources";
|
|
30
|
+
import { hasPermission } from "../access";
|
|
22
31
|
import { assertConfiguredModel, assertWorkspaceModelPolicyAllows } from "../domain/sessions";
|
|
23
32
|
|
|
24
33
|
type NewSessionDraftDependencies = Pick<AppDependencies, "settings" | "db" | "objectStorage">;
|
|
25
34
|
|
|
35
|
+
function hasOwn(value: unknown, key: string): boolean {
|
|
36
|
+
return typeof value === "object" && value !== null && Object.hasOwn(value, key);
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
function mapNewSessionDraft(
|
|
27
40
|
row: Awaited<ReturnType<typeof getNewSessionDraftInTransaction>>,
|
|
28
41
|
): NewSessionDraftValue | null {
|
|
@@ -31,14 +44,110 @@ function mapNewSessionDraft(
|
|
|
31
44
|
revision: row.revision,
|
|
32
45
|
text: row.text,
|
|
33
46
|
resources: row.resources,
|
|
34
|
-
tools: row.tools,
|
|
47
|
+
tools: newSessionDraftToolsProvided(row) ? row.tools : [],
|
|
48
|
+
toolsProvided: newSessionDraftToolsProvided(row),
|
|
35
49
|
model: row.model,
|
|
36
50
|
reasoningEffort: row.reasoningEffort,
|
|
37
|
-
options: row
|
|
51
|
+
options: publicNewSessionDraftOptions(row),
|
|
38
52
|
updatedAt: row.updatedAt.toISOString(),
|
|
39
53
|
});
|
|
40
54
|
}
|
|
41
55
|
|
|
56
|
+
async function hydrateNewSessionDraft(
|
|
57
|
+
deps: Pick<NewSessionDraftDependencies, "db" | "settings">,
|
|
58
|
+
grant: AccessGrant,
|
|
59
|
+
workspaceId: string,
|
|
60
|
+
row: Awaited<ReturnType<typeof getNewSessionDraftInTransaction>>,
|
|
61
|
+
): Promise<NewSessionDraftValue | null> {
|
|
62
|
+
if (!row) return null;
|
|
63
|
+
const mapped = mapNewSessionDraft(row);
|
|
64
|
+
if (!mapped) return null;
|
|
65
|
+
const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
|
|
66
|
+
deps.db,
|
|
67
|
+
workspaceId,
|
|
68
|
+
deps.settings,
|
|
69
|
+
);
|
|
70
|
+
const resources = [] as NewSessionDraftValue["resources"];
|
|
71
|
+
for (const resource of mapped.resources) {
|
|
72
|
+
if (resource.kind === "repository") {
|
|
73
|
+
try {
|
|
74
|
+
await validateGitHubRepositorySelection(deps.db, workspaceId, [resource]);
|
|
75
|
+
resources.push(resource);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (isAuthoritativeGitHubRepositorySelectionError(error)) {
|
|
78
|
+
// Repository authorization can be revoked after the draft was saved.
|
|
79
|
+
// The next form must not present the stale identity as selectable.
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// A catalog/database outage is not proof that a repository was revoked.
|
|
83
|
+
// Preserve the resource so a later retry cannot autosave its deletion.
|
|
84
|
+
resources.push(resource);
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const file = await requireFile(deps.db, workspaceId, resource.fileId);
|
|
90
|
+
if (file.status === "ready") resources.push(resource);
|
|
91
|
+
} catch {
|
|
92
|
+
// Missing, foreign, failed, and pending files are stale draft state.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const options = { ...mapped.options };
|
|
97
|
+
if (options.variableSetId) {
|
|
98
|
+
if (
|
|
99
|
+
!hasPermission(grant.permissions, "variable-sets:use") ||
|
|
100
|
+
!(await getVariableSet(deps.db, workspaceId, options.variableSetId))
|
|
101
|
+
) {
|
|
102
|
+
delete options.variableSetId;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (options.rigId) {
|
|
106
|
+
const rig = await getRig(deps.db, workspaceId, options.rigId);
|
|
107
|
+
if (!rig?.activeVersion) delete options.rigId;
|
|
108
|
+
}
|
|
109
|
+
if (options.targetSandboxId) {
|
|
110
|
+
const sandbox = await getSandbox(deps.db, workspaceId, options.targetSandboxId);
|
|
111
|
+
const enrollment = sandbox?.enrollmentId
|
|
112
|
+
? await getEnrollment(deps.db, workspaceId, sandbox.enrollmentId)
|
|
113
|
+
: null;
|
|
114
|
+
if (
|
|
115
|
+
!sandbox ||
|
|
116
|
+
sandbox.kind !== "selfhosted" ||
|
|
117
|
+
!enrollment ||
|
|
118
|
+
enrollment.status !== "active"
|
|
119
|
+
) {
|
|
120
|
+
delete options.targetSandboxId;
|
|
121
|
+
delete options.workingDir;
|
|
122
|
+
delete options.sandboxBackend;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let tools: NewSessionDraftValue["tools"] = [];
|
|
127
|
+
if (mapped.toolsProvided) {
|
|
128
|
+
try {
|
|
129
|
+
tools = validateToolRefs(mapped.tools, runtimeSettings);
|
|
130
|
+
} catch {
|
|
131
|
+
// A revoked/disabled MCP selection is removed while explicitness remains
|
|
132
|
+
// true, so an explicit empty policy cannot silently widen to defaults.
|
|
133
|
+
tools = mapped.tools.filter((tool) => {
|
|
134
|
+
try {
|
|
135
|
+
validateToolRefs([tool], runtimeSettings);
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
...mapped,
|
|
145
|
+
resources,
|
|
146
|
+
tools,
|
|
147
|
+
options,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
42
151
|
/** Read the authenticated actor's server-authoritative pre-session composer state. */
|
|
43
152
|
export async function getActorNewSessionDraft(
|
|
44
153
|
deps: Pick<NewSessionDraftDependencies, "settings" | "db">,
|
|
@@ -52,11 +161,12 @@ export async function getActorNewSessionDraft(
|
|
|
52
161
|
}),
|
|
53
162
|
);
|
|
54
163
|
return (
|
|
55
|
-
|
|
164
|
+
(await hydrateNewSessionDraft(deps, grant, workspaceId, row)) ?? {
|
|
56
165
|
revision: 0,
|
|
57
166
|
text: "",
|
|
58
167
|
resources: [],
|
|
59
168
|
tools: [],
|
|
169
|
+
toolsProvided: false,
|
|
60
170
|
model: deps.settings.openaiModel,
|
|
61
171
|
reasoningEffort: deps.settings.openaiReasoningEffort,
|
|
62
172
|
options: {},
|
|
@@ -79,13 +189,18 @@ export async function saveActorNewSessionDraft(
|
|
|
79
189
|
rawInput: unknown,
|
|
80
190
|
): Promise<NewSessionDraftValue> {
|
|
81
191
|
const input = SaveNewSessionDraftRequest.parse(rawInput);
|
|
192
|
+
// The pre-marker client contract required `tools` and had no
|
|
193
|
+
// `toolsProvided`. Its array—including []—was the user's complete selection.
|
|
194
|
+
// Do this presence check before Zod's default turns the missing marker into
|
|
195
|
+
// false, preserving old-client → new-server intent safely.
|
|
196
|
+
const toolsProvided = hasOwn(rawInput, "toolsProvided") ? input.toolsProvided : true;
|
|
82
197
|
const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
|
|
83
198
|
deps.db,
|
|
84
199
|
workspaceId,
|
|
85
200
|
deps.settings,
|
|
86
201
|
);
|
|
87
202
|
const resources = normalizeResources(input.resources);
|
|
88
|
-
const tools = validateToolRefs(input.tools, runtimeSettings);
|
|
203
|
+
const tools = toolsProvided ? validateToolRefs(input.tools, runtimeSettings) : [];
|
|
89
204
|
await validateGitHubRepositorySelection(deps.db, workspaceId, resources);
|
|
90
205
|
if (resources.some((resource) => resource.kind === "file") && !deps.objectStorage) {
|
|
91
206
|
throw new HTTPException(503, { message: "object storage is not configured" });
|
|
@@ -105,6 +220,7 @@ export async function saveActorNewSessionDraft(
|
|
|
105
220
|
text: input.text,
|
|
106
221
|
resources,
|
|
107
222
|
tools,
|
|
223
|
+
toolsProvided,
|
|
108
224
|
model: input.model,
|
|
109
225
|
reasoningEffort: input.reasoningEffort,
|
|
110
226
|
options: input.options,
|
package/src/dependencies.ts
CHANGED
|
@@ -106,6 +106,8 @@ export type AppDependencies = {
|
|
|
106
106
|
managedAuth?: ManagedAuth | null;
|
|
107
107
|
/** Injectable Codex HTTP transport for deterministic API/provider tests. */
|
|
108
108
|
codexFetch?: typeof fetch;
|
|
109
|
+
/** Injectable Slack Web API transport for deterministic bot-connection tests. */
|
|
110
|
+
slackFetch?: typeof fetch;
|
|
109
111
|
// The API process's OWN agent-loop-free sandbox client (constructed from
|
|
110
112
|
// settings via @opengeni/runtime/sandbox). Undefined when sandboxBackend=none.
|
|
111
113
|
// This is the foundation of the API-direct control plane: the API resumes
|
package/src/domain/resources.ts
CHANGED
|
@@ -301,6 +301,15 @@ export async function validateGitHubRepositorySelection(
|
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
/**
|
|
305
|
+
* A 422 from repository selection validation is an authoritative stale or
|
|
306
|
+
* revoked identity. Other failures (for example a database/catalog outage)
|
|
307
|
+
* leave the result unknown and must not cause draft hydration to delete it.
|
|
308
|
+
*/
|
|
309
|
+
export function isAuthoritativeGitHubRepositorySelectionError(error: unknown): boolean {
|
|
310
|
+
return error instanceof HTTPException && error.status === 422;
|
|
311
|
+
}
|
|
312
|
+
|
|
304
313
|
export async function validateFileResources(
|
|
305
314
|
db: Database,
|
|
306
315
|
workspaceId: string,
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
CreateScheduledTaskRequest as CreateScheduledTaskPayload,
|
|
7
7
|
UpdateScheduledTaskRequest as UpdateScheduledTaskPayload,
|
|
8
8
|
} from "@opengeni/contracts";
|
|
9
|
+
import { OPENGENI_SLACK_BOT_SESSION_METADATA_KEY } from "@opengeni/contracts";
|
|
9
10
|
import {
|
|
10
11
|
createScheduledTask,
|
|
11
12
|
deleteScheduledTask,
|
|
@@ -24,6 +25,10 @@ import type { ObjectStorageDependency } from "../dependencies";
|
|
|
24
25
|
import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
|
|
25
26
|
import { validateVariableSetAttachment } from "./environments";
|
|
26
27
|
import { assertWorkspaceModelPolicyAllows, canonicalConfiguredModel } from "./sessions";
|
|
28
|
+
import {
|
|
29
|
+
hasReservedOpenGeniSlackBotSessionMetadata,
|
|
30
|
+
validateOpenGeniSlackBotConnectionSelection,
|
|
31
|
+
} from "./slack-bot";
|
|
27
32
|
import {
|
|
28
33
|
normalizeResources,
|
|
29
34
|
validateFileResources,
|
|
@@ -197,7 +202,7 @@ export async function validatedScheduledTaskUpdate(input: {
|
|
|
197
202
|
if (willHaveVariableSet) {
|
|
198
203
|
requirePermission(input.grant, "variable-sets:use");
|
|
199
204
|
}
|
|
200
|
-
|
|
205
|
+
const nextAgentConfig = await validateScheduledTaskAgentConfig({
|
|
201
206
|
settings: input.settings,
|
|
202
207
|
db: input.db,
|
|
203
208
|
objectStorage: input.objectStorage,
|
|
@@ -206,6 +211,18 @@ export async function validatedScheduledTaskUpdate(input: {
|
|
|
206
211
|
payload: { agentConfig: input.payload.agentConfig },
|
|
207
212
|
...(input.toolsProvided !== undefined ? { toolsProvided: input.toolsProvided } : {}),
|
|
208
213
|
});
|
|
214
|
+
if (
|
|
215
|
+
input.existing.reusableSessionId &&
|
|
216
|
+
input.existing.runMode === "reusable_session" &&
|
|
217
|
+
(input.existing.agentConfig.slackBotConnectionId ?? null) !==
|
|
218
|
+
(nextAgentConfig.slackBotConnectionId ?? null)
|
|
219
|
+
) {
|
|
220
|
+
throw new HTTPException(409, {
|
|
221
|
+
message:
|
|
222
|
+
"cannot change the OpenGeni Slack bot connection of a task with a live reusable session; recreate the task",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
update.agentConfig = nextAgentConfig;
|
|
209
226
|
}
|
|
210
227
|
return update;
|
|
211
228
|
}
|
|
@@ -355,11 +372,24 @@ async function validateScheduledTaskAgentConfig(input: {
|
|
|
355
372
|
if (!prompt) {
|
|
356
373
|
throw new HTTPException(422, { message: "scheduled task prompt is required" });
|
|
357
374
|
}
|
|
375
|
+
if (hasReservedOpenGeniSlackBotSessionMetadata(input.payload.agentConfig.metadata)) {
|
|
376
|
+
throw new HTTPException(422, {
|
|
377
|
+
message: `${OPENGENI_SLACK_BOT_SESSION_METADATA_KEY} is reserved for scheduler routing`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
358
380
|
await validateGitHubRepositorySelection(input.db, input.workspaceId, resources);
|
|
359
381
|
if (resources.some((resource) => resource.kind === "file") && !input.objectStorage) {
|
|
360
382
|
throw new HTTPException(503, { message: "object storage is not configured" });
|
|
361
383
|
}
|
|
362
384
|
await validateFileResources(input.db, input.workspaceId, resources);
|
|
385
|
+
if (input.payload.agentConfig.slackBotConnectionId) {
|
|
386
|
+
await validateOpenGeniSlackBotConnectionSelection(
|
|
387
|
+
input.db,
|
|
388
|
+
input.grant,
|
|
389
|
+
input.workspaceId,
|
|
390
|
+
input.payload.agentConfig.slackBotConnectionId,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
363
393
|
const requestedMaxDepth = input.payload.agentConfig.maxNestedAgentDepth;
|
|
364
394
|
if (requestedMaxDepth !== undefined) {
|
|
365
395
|
const workspace = await requireWorkspace(input.db, input.workspaceId);
|
|
@@ -113,18 +113,17 @@ export function resolveSessionToolPolicy(input: SessionToolPolicyInput): Resolve
|
|
|
113
113
|
const configuredIds = effectiveIds.filter((id) => availableIds.has(id));
|
|
114
114
|
const configuredIdSet = new Set(configuredIds);
|
|
115
115
|
const droppedIds = effectiveIds.filter((id) => !configuredIdSet.has(id));
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
: [];
|
|
116
|
+
// Lazy discovery is scoped to the effective MCP allow-list, not only to
|
|
117
|
+
// workspace-default capability refs. An explicitly selected connector is
|
|
118
|
+
// directly callable from this same materialized list, so excluding it from
|
|
119
|
+
// the existing router creates a false "no connected sources" result. The
|
|
120
|
+
// mandatory first-party OpenGeni server stays eager; every other configured
|
|
121
|
+
// effective server is eligible for bounded schema disclosure.
|
|
122
|
+
const deferredIds = sortedIds(
|
|
123
|
+
toolRefs
|
|
124
|
+
.filter((tool) => configuredIdSet.has(tool.id) && !mandatoryIdSet.has(tool.id))
|
|
125
|
+
.map((tool) => tool.id),
|
|
126
|
+
);
|
|
128
127
|
const selectedIds = sortedIds(
|
|
129
128
|
selectedRefs
|
|
130
129
|
.filter(
|
|
@@ -151,7 +150,7 @@ export function resolveSessionToolPolicy(input: SessionToolPolicyInput): Resolve
|
|
|
151
150
|
effectiveIds: projections.effective.ids,
|
|
152
151
|
mandatoryIds: projections.mandatory.ids,
|
|
153
152
|
lazyRouter: {
|
|
154
|
-
state:
|
|
153
|
+
state: deferredIds.length > 0 ? "required" : "disabled",
|
|
155
154
|
deferredIds: projections.deferred.ids,
|
|
156
155
|
},
|
|
157
156
|
configuredIds: projections.configured.ids,
|
|
@@ -169,6 +168,19 @@ export function resolveSessionToolPolicy(input: SessionToolPolicyInput): Resolve
|
|
|
169
168
|
};
|
|
170
169
|
}
|
|
171
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Native provider tools that belong to the workspace-default capability set
|
|
173
|
+
* follow the same omission/narrowing fence as deferred MCP tools. A durable
|
|
174
|
+
* workspace-default policy receives them; fixed historical policies and an
|
|
175
|
+
* explicit per-turn replacement do not. Provider support remains a separate
|
|
176
|
+
* runtime gate and must also be true before a native tool is attached.
|
|
177
|
+
*/
|
|
178
|
+
export function sessionToolPolicyAllowsDefaultNativeTools(
|
|
179
|
+
policy: SessionEffectiveToolPolicy,
|
|
180
|
+
): boolean {
|
|
181
|
+
return policy.mode === "workspace_default" && policy.lazyRouter.state === "required";
|
|
182
|
+
}
|
|
183
|
+
|
|
172
184
|
/** Current full runtime registry IDs, including configured static servers. */
|
|
173
185
|
export async function workspaceSessionToolPolicyServerIds(
|
|
174
186
|
db: Database,
|
package/src/domain/sessions.ts
CHANGED
|
@@ -9,11 +9,13 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
CreateSessionRequest,
|
|
11
11
|
DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
|
|
12
|
+
OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
|
|
12
13
|
SessionSpawnDenial,
|
|
13
14
|
ServiceTurnInitiator,
|
|
14
15
|
ServiceTurnInitiatorContext,
|
|
15
16
|
evaluateWorkspaceModelPolicy,
|
|
16
17
|
reasoningEffortForMetadata,
|
|
18
|
+
stableJson,
|
|
17
19
|
type AccessGrant,
|
|
18
20
|
type CreateSessionResponse,
|
|
19
21
|
type GoalSpec,
|
|
@@ -27,6 +29,7 @@ import {
|
|
|
27
29
|
type SessionMcpServerInput,
|
|
28
30
|
type SessionMcpServerMetadata,
|
|
29
31
|
type UpdateSessionMcpApprovalPolicyResponse,
|
|
32
|
+
type UpdateSessionToolPolicyRequest,
|
|
30
33
|
type SessionAuthorizationPort,
|
|
31
34
|
type SessionToolPolicy,
|
|
32
35
|
type SessionTurn,
|
|
@@ -69,6 +72,7 @@ import {
|
|
|
69
72
|
AgentCommandAuthorityError,
|
|
70
73
|
SessionSpawnDeniedDbError,
|
|
71
74
|
SessionControlConflictError,
|
|
75
|
+
SessionToolPolicyVersionConflictError,
|
|
72
76
|
type SessionCommandActor,
|
|
73
77
|
} from "@opengeni/db";
|
|
74
78
|
import {
|
|
@@ -89,6 +93,7 @@ import { requireSessionAuthorization } from "../session-authorization";
|
|
|
89
93
|
import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
|
|
90
94
|
import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
|
|
91
95
|
import { requireVariableSetEncryption, validateVariableSetAttachment } from "./environments";
|
|
96
|
+
import { hasReservedOpenGeniSlackBotSessionMetadata } from "./slack-bot";
|
|
92
97
|
import {
|
|
93
98
|
assertToolRefsSubset,
|
|
94
99
|
availableToolRefs,
|
|
@@ -104,6 +109,9 @@ import {
|
|
|
104
109
|
const reservedSessionMcpServerIds = new Set(["opengeni", "files", "docs", "codex_apps"]);
|
|
105
110
|
const maxSessionMcpCredentialHeaders = 16;
|
|
106
111
|
const maxSessionMcpCredentialHeaderValueLength = 4096;
|
|
112
|
+
// Keep the durable snapshot below the shared event-preview array boundary so
|
|
113
|
+
// the generic lossy projection cannot silently rewrite this audit fact.
|
|
114
|
+
const maxToolPolicyAuditRefs = 40;
|
|
107
115
|
// RFC 9110 field-name token characters.
|
|
108
116
|
const sessionMcpCredentialHeaderName = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
109
117
|
|
|
@@ -1021,6 +1029,11 @@ export async function createSessionForRequest(
|
|
|
1021
1029
|
): Promise<Session> {
|
|
1022
1030
|
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
1023
1031
|
const payload = CreateSessionRequest.parse(rawPayload);
|
|
1032
|
+
if (hasReservedOpenGeniSlackBotSessionMetadata(payload.metadata)) {
|
|
1033
|
+
throw new HTTPException(422, {
|
|
1034
|
+
message: `${OPENGENI_SLACK_BOT_SESSION_METADATA_KEY} is reserved for scheduler routing`,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1024
1037
|
// A committed keyed denial is the idempotent outcome even if mutable
|
|
1025
1038
|
// resources, policy, authorization, or budget have changed since the first
|
|
1026
1039
|
// attempt. Replay it before any of those checks, just as a keyed successful
|
|
@@ -1856,6 +1869,200 @@ export async function updateSessionMcpApprovalPolicy(
|
|
|
1856
1869
|
};
|
|
1857
1870
|
}
|
|
1858
1871
|
|
|
1872
|
+
function toolPolicyAuditSnapshot(
|
|
1873
|
+
session: Session,
|
|
1874
|
+
tools: ToolRef[],
|
|
1875
|
+
policy = session.toolPolicy ?? { mode: "legacy" as const, inheritedFromSessionId: null },
|
|
1876
|
+
) {
|
|
1877
|
+
// Tool policy refs contain only public server ids and the optional/strict
|
|
1878
|
+
// execution mode; they never carry URLs, names, headers, credentials,
|
|
1879
|
+
// schemas, or arguments. The request is capped at 64 refs and the mandatory
|
|
1880
|
+
// first-party server can add one more, so the complete snapshot remains a
|
|
1881
|
+
// small bounded payload rather than silently dropping security-relevant
|
|
1882
|
+
// optional/strict changes.
|
|
1883
|
+
const allToolRefs = mergeToolRefs([], tools)
|
|
1884
|
+
.sort((left, right) => {
|
|
1885
|
+
// Keep the mandatory first-party authority visible even when the
|
|
1886
|
+
// bounded audit preview has to omit the middle of a large selection.
|
|
1887
|
+
const leftMandatory = left.kind === "mcp" && left.id === "opengeni";
|
|
1888
|
+
const rightMandatory = right.kind === "mcp" && right.id === "opengeni";
|
|
1889
|
+
if (leftMandatory !== rightMandatory) return leftMandatory ? -1 : 1;
|
|
1890
|
+
return `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`);
|
|
1891
|
+
})
|
|
1892
|
+
.map((tool) => ({
|
|
1893
|
+
kind: tool.kind,
|
|
1894
|
+
id: tool.id,
|
|
1895
|
+
...(tool.optional === undefined ? {} : { optional: tool.optional }),
|
|
1896
|
+
}));
|
|
1897
|
+
const toolRefs = allToolRefs.slice(0, maxToolPolicyAuditRefs);
|
|
1898
|
+
return {
|
|
1899
|
+
mode: policy.mode,
|
|
1900
|
+
inheritedFromSessionId: policy.inheritedFromSessionId,
|
|
1901
|
+
// IDs only: no MCP URLs, names, headers, credentials, schemas, or args.
|
|
1902
|
+
toolIds: [...toolRefs]
|
|
1903
|
+
.sort((left, right) => `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`))
|
|
1904
|
+
.map((tool) => tool.id),
|
|
1905
|
+
toolRefs,
|
|
1906
|
+
toolCount: allToolRefs.length,
|
|
1907
|
+
truncated: allToolRefs.length > toolRefs.length,
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* Replace the durable session tool policy. The target and its parent (when
|
|
1913
|
+
* present) are locked by the DB event-writer helper, and the update/event are
|
|
1914
|
+
* committed under one version-fenced transaction. An already claimed turn
|
|
1915
|
+
* keeps its immutable snapshot; the next attempt observes this policy.
|
|
1916
|
+
*/
|
|
1917
|
+
export async function updateSessionToolPolicy(
|
|
1918
|
+
deps: {
|
|
1919
|
+
db: Database;
|
|
1920
|
+
bus: EventBus;
|
|
1921
|
+
settings: Settings;
|
|
1922
|
+
sessionAuthorization?: SessionAuthorizationPort | null;
|
|
1923
|
+
},
|
|
1924
|
+
grant: AccessGrant,
|
|
1925
|
+
sessionId: string,
|
|
1926
|
+
request: UpdateSessionToolPolicyRequest,
|
|
1927
|
+
): Promise<Session> {
|
|
1928
|
+
await requireSessionAuthorization(deps, grant, {
|
|
1929
|
+
sessionId,
|
|
1930
|
+
operation: "session.tool_policy.write",
|
|
1931
|
+
surface: "core",
|
|
1932
|
+
});
|
|
1933
|
+
requirePermission(grant, "sessions:control");
|
|
1934
|
+
|
|
1935
|
+
const existingSession = await requireSession(deps.db, grant.workspaceId, sessionId);
|
|
1936
|
+
const capabilityRuntimeSettings = await settingsWithEnabledCapabilityMcpServers(
|
|
1937
|
+
deps.db,
|
|
1938
|
+
grant.workspaceId,
|
|
1939
|
+
deps.settings,
|
|
1940
|
+
);
|
|
1941
|
+
const runtimeSettings = settingsWithSessionMcpServerMetadata(
|
|
1942
|
+
capabilityRuntimeSettings,
|
|
1943
|
+
existingSession.mcpServers,
|
|
1944
|
+
);
|
|
1945
|
+
const explicitRequest = request.mode === "workspace_default" ? null : request;
|
|
1946
|
+
const requestedMode = explicitRequest ? "explicit" : "workspace_default";
|
|
1947
|
+
const explicitRequestedTools = explicitRequest
|
|
1948
|
+
? (() => {
|
|
1949
|
+
const validatedTools = validateToolRefs(explicitRequest.tools, runtimeSettings);
|
|
1950
|
+
const validatedIds = new Set(validatedTools.map((tool) => `${tool.kind}:${tool.id}`));
|
|
1951
|
+
const unknown = explicitRequest.tools.find(
|
|
1952
|
+
(tool) => !validatedIds.has(`${tool.kind}:${tool.id}`),
|
|
1953
|
+
);
|
|
1954
|
+
if (unknown) {
|
|
1955
|
+
throw new HTTPException(422, { message: `unknown MCP server id: ${unknown.id}` });
|
|
1956
|
+
}
|
|
1957
|
+
return withFirstPartyTools(validatedTools, runtimeSettings);
|
|
1958
|
+
})()
|
|
1959
|
+
: null;
|
|
1960
|
+
const workspaceDefaultTools = withFirstPartyTools(
|
|
1961
|
+
withDefaultEnabledCapabilityMcpTools([], deps.settings, capabilityRuntimeSettings),
|
|
1962
|
+
runtimeSettings,
|
|
1963
|
+
);
|
|
1964
|
+
const events = await appendSessionEventsWithLockedSessionUpdate(
|
|
1965
|
+
deps.db,
|
|
1966
|
+
grant.workspaceId,
|
|
1967
|
+
sessionId,
|
|
1968
|
+
async (session, context) => {
|
|
1969
|
+
const currentVersion = session.toolPolicyVersion ?? 1;
|
|
1970
|
+
if (request.expectedVersion !== currentVersion) {
|
|
1971
|
+
throw new SessionToolPolicyVersionConflictError(currentVersion);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
let nextTools: ToolRef[];
|
|
1975
|
+
let nextPolicy: SessionToolPolicy;
|
|
1976
|
+
if (session.parentSessionId) {
|
|
1977
|
+
const parent = await context.getLockedSession(session.parentSessionId);
|
|
1978
|
+
if (!parent) {
|
|
1979
|
+
throw new HTTPException(409, { message: "parent session is no longer available" });
|
|
1980
|
+
}
|
|
1981
|
+
const parentTracksWorkspaceDefaults = parent.toolPolicy?.mode === "workspace_default";
|
|
1982
|
+
const parentEffective = withFirstPartyTools(
|
|
1983
|
+
parentTracksWorkspaceDefaults
|
|
1984
|
+
? withDefaultEnabledCapabilityMcpTools(
|
|
1985
|
+
availableToolRefs(parent.tools, runtimeSettings),
|
|
1986
|
+
deps.settings,
|
|
1987
|
+
runtimeSettings,
|
|
1988
|
+
)
|
|
1989
|
+
: parent.tools,
|
|
1990
|
+
runtimeSettings,
|
|
1991
|
+
);
|
|
1992
|
+
if (requestedMode === "workspace_default") {
|
|
1993
|
+
if (!parentTracksWorkspaceDefaults) {
|
|
1994
|
+
throw new HTTPException(403, {
|
|
1995
|
+
message:
|
|
1996
|
+
"a child may adopt workspace defaults only while its parent tracks workspace defaults",
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
nextTools = parentEffective;
|
|
2000
|
+
nextPolicy = {
|
|
2001
|
+
mode: "workspace_default",
|
|
2002
|
+
inheritedFromSessionId: parent.id,
|
|
2003
|
+
};
|
|
2004
|
+
} else {
|
|
2005
|
+
nextTools = explicitRequestedTools!;
|
|
2006
|
+
assertToolRefsSubset(
|
|
2007
|
+
nextTools,
|
|
2008
|
+
parentEffective,
|
|
2009
|
+
"session tools may only narrow the parent session tool policy",
|
|
2010
|
+
);
|
|
2011
|
+
nextPolicy = {
|
|
2012
|
+
mode: "explicit",
|
|
2013
|
+
inheritedFromSessionId: parent.id,
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
} else {
|
|
2017
|
+
nextTools =
|
|
2018
|
+
requestedMode === "workspace_default" ? workspaceDefaultTools : explicitRequestedTools!;
|
|
2019
|
+
nextPolicy = { mode: requestedMode, inheritedFromSessionId: null };
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const currentPolicy = session.toolPolicy ?? {
|
|
2023
|
+
mode: "legacy" as const,
|
|
2024
|
+
inheritedFromSessionId: null,
|
|
2025
|
+
};
|
|
2026
|
+
// JSONB normalizes object-key order on the round trip, so plain
|
|
2027
|
+
// JSON.stringify would turn an identical retry into a second mutation
|
|
2028
|
+
// (and version bump) merely because the persisted key order differs from
|
|
2029
|
+
// the request object. Compare canonical JSON instead.
|
|
2030
|
+
const unchanged =
|
|
2031
|
+
stableJson({ tools: session.tools, policy: currentPolicy }) ===
|
|
2032
|
+
stableJson({ tools: nextTools, policy: nextPolicy });
|
|
2033
|
+
if (unchanged) {
|
|
2034
|
+
return { events: [] };
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
const nextVersion = currentVersion + 1;
|
|
2038
|
+
return {
|
|
2039
|
+
events: [
|
|
2040
|
+
{
|
|
2041
|
+
type: "session.tool_policy.updated" as const,
|
|
2042
|
+
payload: {
|
|
2043
|
+
before: toolPolicyAuditSnapshot(session, session.tools, currentPolicy),
|
|
2044
|
+
after: toolPolicyAuditSnapshot(session, nextTools, nextPolicy),
|
|
2045
|
+
version: nextVersion,
|
|
2046
|
+
effectiveFrom: "next_attempt",
|
|
2047
|
+
},
|
|
2048
|
+
},
|
|
2049
|
+
],
|
|
2050
|
+
update: {
|
|
2051
|
+
tools: nextTools,
|
|
2052
|
+
toolPolicy: nextPolicy,
|
|
2053
|
+
toolPolicyVersion: nextVersion,
|
|
2054
|
+
expectedToolPolicyVersion: request.expectedVersion,
|
|
2055
|
+
},
|
|
2056
|
+
};
|
|
2057
|
+
},
|
|
2058
|
+
{ lockParentSession: true },
|
|
2059
|
+
);
|
|
2060
|
+
if (events.length > 0) {
|
|
2061
|
+
await publishDurableSessionEvents(deps.bus, grant.workspaceId, sessionId, events);
|
|
2062
|
+
}
|
|
2063
|
+
return await requireSession(deps.db, grant.workspaceId, sessionId);
|
|
2064
|
+
}
|
|
2065
|
+
|
|
1859
2066
|
export async function readSessionLineage(
|
|
1860
2067
|
deps: Pick<ApiRouteDeps, "db" | "sessionAuthorization">,
|
|
1861
2068
|
grant: AccessGrant,
|