@opengeni/core 0.4.10 → 0.8.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.
@@ -36,6 +36,11 @@ import {
36
36
  type EnabledMcpCapabilityServer,
37
37
  } from "@opengeni/db";
38
38
  import { HTTPException } from "hono/http-exception";
39
+ import {
40
+ getSkillLibraryEntry,
41
+ listSkillLibraryEntries,
42
+ type SkillLibraryEntry,
43
+ } from "@opengeni/runtime/skill-library";
39
44
  import { validateVariableSetAttachment } from "./environments";
40
45
  import {
41
46
  assertPackSandboxImageCompatible,
@@ -65,12 +70,14 @@ export async function buildCapabilityCatalog(input: {
65
70
  packInstallations,
66
71
  workspacePacks,
67
72
  bundledSkills,
73
+ curatedLibrarySkills,
68
74
  ] = await Promise.all([
69
75
  listCapabilityCatalogItems(input.db, input.workspaceId),
70
76
  listCapabilityInstallations(input.db, input.workspaceId),
71
77
  listPackInstallations(input.db, input.workspaceId),
72
78
  listWorkspaceCapabilityPacks(input.db, input.workspaceId),
73
79
  discoverBundledSkills(),
80
+ discoverCuratedSkillLibraryItems(),
74
81
  ]);
75
82
  const capabilityInstallationById = new Map(
76
83
  capabilityInstallations.map((installation) => [installation.capabilityId, installation]),
@@ -88,6 +95,7 @@ export async function buildCapabilityCatalog(input: {
88
95
  ...configuredMcpCatalogItems(input.settings),
89
96
  ...platformApiCatalogItems(),
90
97
  ...bundledSkills,
98
+ ...curatedLibrarySkills,
91
99
  ];
92
100
  const items = dedupeCatalogItems([...builtIns, ...persistedItems])
93
101
  .map((item) =>
@@ -112,8 +120,14 @@ export async function createCatalogItem(input: {
112
120
  message: "packs are managed by OpenGeni and cannot be manually created",
113
121
  });
114
122
  }
123
+ if (id.startsWith("skill:")) {
124
+ throw new HTTPException(422, {
125
+ message: "skill ids are managed by the OpenGeni skill library or runtime adapters",
126
+ });
127
+ }
115
128
  const source =
116
129
  input.payload.source === "built_in" ||
130
+ input.payload.source === "library" ||
117
131
  input.payload.source === "configured" ||
118
132
  input.payload.source === "registry"
119
133
  ? "manual"
@@ -169,11 +183,49 @@ export async function enableCapability(input: {
169
183
  // Credential-header storage is written exclusively by this flow; strip the
170
184
  // reserved keys from caller-provided config so the stored shape stays
171
185
  // trustworthy and no plaintext credentials sneak in through config.headers.
172
- const installationConfig: Record<string, unknown> = { ...input.payload.config };
186
+ let installationConfig: Record<string, unknown> = { ...input.payload.config };
173
187
  delete installationConfig.headers;
174
188
  delete installationConfig.headersEncrypted;
175
189
  delete installationConfig.headerNames;
176
190
  delete installationConfig.connectionRef;
191
+ if (item.kind === "skill" && item.source === "library") {
192
+ const libraryId = stringMetadata(item.metadata.libraryId);
193
+ const catalogVersion = stringMetadata(item.metadata.version);
194
+ if (!libraryId || !catalogVersion) {
195
+ throw new HTTPException(422, {
196
+ message: `skill library metadata is incomplete for ${item.id}`,
197
+ });
198
+ }
199
+ const requestedVersion = input.payload.config.version;
200
+ if (requestedVersion !== undefined && typeof requestedVersion !== "string") {
201
+ throw new HTTPException(422, {
202
+ message: "skill activation config.version must be a string",
203
+ });
204
+ }
205
+ const normalizedVersion = requestedVersion?.trim() || catalogVersion;
206
+ if (normalizedVersion !== catalogVersion) {
207
+ throw new HTTPException(422, {
208
+ message: `skill ${libraryId} only supports immutable version ${catalogVersion}`,
209
+ });
210
+ }
211
+ const entry = getSkillLibraryEntry(libraryId, normalizedVersion);
212
+ if (!entry) {
213
+ throw new HTTPException(422, {
214
+ message: `skill library entry is unavailable: ${libraryId}@${normalizedVersion}`,
215
+ });
216
+ }
217
+ // Skill activation has a deliberately narrow config surface. In
218
+ // particular, no variable-set, connection, credential-header, MCP, or
219
+ // provider-routing input is persisted or consulted for a library skill.
220
+ installationConfig = { version: entry.version };
221
+ installationMetadata = {
222
+ libraryId: entry.id,
223
+ libraryVersion: entry.version,
224
+ contentSha256: entry.contentSha256,
225
+ sourceCommit: entry.sourceCommit,
226
+ provenance: entry.provenance,
227
+ };
228
+ }
177
229
  if (item.kind === "mcp") {
178
230
  const headers = await resolveMcpCredentialHeaders(input, item);
179
231
  const connectionRef = input.payload.connectionRef
@@ -381,9 +433,13 @@ async function validateMcpCapabilityConnectionRef(
381
433
  providerDomain: ref.providerDomain.trim(),
382
434
  subjectScope: "workspace",
383
435
  ...(ref.connectionId ? { connectionId: ref.connectionId } : {}),
436
+ ...(ref.provider ? { provider: ref.provider.trim() } : {}),
384
437
  ...(ref.kind ? { kind: ref.kind } : {}),
385
438
  ...(ref.scopes ? { scopes: uniqueStrings(ref.scopes) } : {}),
386
439
  ...(ref.resource ? { resource: ref.resource } : {}),
440
+ ...(ref.selectedResources
441
+ ? { selectedResources: ref.selectedResources.map((resource) => ({ ...resource })) }
442
+ : {}),
387
443
  };
388
444
  if (!normalized.providerDomain) {
389
445
  throw new HTTPException(422, { message: "connectionRef.providerDomain is required" });
@@ -568,14 +624,23 @@ async function probeStreamableHttpMcpServer(
568
624
  function mcpProbeErrorMessage(error: unknown, endpointUrl: string): string {
569
625
  const message = error instanceof Error ? error.message : String(error);
570
626
  const normalized = message.replace(/\s+/g, " ").trim();
627
+ const endpoint = safeEndpointLabel(endpointUrl);
571
628
  if (
572
629
  /404|405|not found|unexpected token|not valid json|invalid json|failed to parse|streamable http error|unable to connect|fetch failed|econnrefused|enotfound|timeout|aborted/i.test(
573
630
  normalized,
574
631
  )
575
632
  ) {
576
- return `OpenGeni could not reach a valid Streamable HTTP MCP server at ${endpointUrl}. Check the endpoint URL or choose a different catalog entry.`;
633
+ return `OpenGeni could not reach a valid Streamable HTTP MCP server at ${endpoint}. Check the endpoint URL or choose a different catalog entry.`;
634
+ }
635
+ return `OpenGeni could not initialize ${endpoint}. Check the endpoint configuration or try again.`;
636
+ }
637
+
638
+ function safeEndpointLabel(endpointUrl: string): string {
639
+ try {
640
+ return new URL(endpointUrl).hostname || "the configured endpoint";
641
+ } catch {
642
+ return "the configured endpoint";
577
643
  }
578
- return `OpenGeni could not initialize ${endpointUrl}: ${normalized.slice(0, 500) || "unknown error"}`;
579
644
  }
580
645
 
581
646
  export async function disableCapability(input: {
@@ -927,6 +992,52 @@ async function discoverBundledSkills(): Promise<CapabilityCatalogItem[]> {
927
992
  }
928
993
  }
929
994
 
995
+ /**
996
+ * Curated skills are catalogued independently from the always-mounted bundle.
997
+ * The runtime helper only returns entries whose reviewed artifact is present,
998
+ * so a deployment that omits optional library assets does not advertise a
999
+ * skill it cannot materialize.
1000
+ */
1001
+ async function discoverCuratedSkillLibraryItems(): Promise<CapabilityCatalogItem[]> {
1002
+ return listSkillLibraryEntries().map((entry) => curatedSkillCatalogItem(entry));
1003
+ }
1004
+
1005
+ function curatedSkillCatalogItem(entry: SkillLibraryEntry): CapabilityCatalogItem {
1006
+ return CapabilityCatalogItem.parse({
1007
+ id: `skill:${entry.id}`,
1008
+ kind: "skill",
1009
+ source: "library",
1010
+ name: entry.name,
1011
+ description: entry.description,
1012
+ category: entry.category,
1013
+ tags: [...entry.tags],
1014
+ homepageUrl: entry.sourceUrl,
1015
+ provenance: entry.provenance,
1016
+ tier: "verified",
1017
+ runtime: {
1018
+ available: true,
1019
+ notes: "Available as an explicit immutable opt-in skill selection.",
1020
+ },
1021
+ metadata: {
1022
+ libraryId: entry.id,
1023
+ version: entry.version,
1024
+ contentSha256: entry.contentSha256,
1025
+ sourceCommit: entry.sourceCommit,
1026
+ sourceUrl: entry.sourceUrl,
1027
+ provenance: entry.provenance,
1028
+ license: entry.license,
1029
+ documentationUrl: entry.documentationUrl,
1030
+ compatibility: entry.compatibility,
1031
+ upgrade: entry.upgrade,
1032
+ artifactPath: entry.relativePath,
1033
+ },
1034
+ });
1035
+ }
1036
+
1037
+ function stringMetadata(value: unknown): string | null {
1038
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
1039
+ }
1040
+
930
1041
  async function readSkillMetadata(
931
1042
  url: URL,
932
1043
  fallbackName: string,
@@ -977,6 +1088,16 @@ export function applyCapabilityEnablement(
977
1088
  enabledReason: item.source === "configured" ? "configured" : "built in",
978
1089
  };
979
1090
  }
1091
+ if (item.source === "library") {
1092
+ const enabled =
1093
+ installation?.status === "active" && skillLibraryInstallationRuntimeReady(item, installation);
1094
+ return {
1095
+ ...item,
1096
+ enabled,
1097
+ enabledReason: enabled ? "explicitly selected" : null,
1098
+ connectionRef: null,
1099
+ };
1100
+ }
980
1101
  const activeInstallation = installation?.status === "active";
981
1102
  const enabled = !!activeInstallation && capabilityInstallationRuntimeReady(item, installation);
982
1103
  return {
@@ -987,6 +1108,42 @@ export function applyCapabilityEnablement(
987
1108
  };
988
1109
  }
989
1110
 
1111
+ function skillLibraryInstallationRuntimeReady(
1112
+ item: CapabilityCatalogItem,
1113
+ installation: CapabilityInstallation,
1114
+ ): boolean {
1115
+ if (item.kind !== "skill" || item.source !== "library" || installation.kind !== "skill") {
1116
+ return false;
1117
+ }
1118
+ const libraryId = stringMetadata(item.metadata.libraryId);
1119
+ const version = stringMetadata(item.metadata.version);
1120
+ const contentSha256 = stringMetadata(item.metadata.contentSha256);
1121
+ const sourceCommit = stringMetadata(item.metadata.sourceCommit);
1122
+ const provenance = stringMetadata(item.metadata.provenance);
1123
+ if (!libraryId || !version || !contentSha256 || !sourceCommit || !provenance) {
1124
+ return false;
1125
+ }
1126
+ const entry = getSkillLibraryEntry(libraryId, version);
1127
+ if (
1128
+ !entry ||
1129
+ item.id !== `skill:${entry.id}` ||
1130
+ installation.capabilityId !== `skill:${entry.id}` ||
1131
+ contentSha256 !== entry.contentSha256 ||
1132
+ sourceCommit !== entry.sourceCommit ||
1133
+ provenance !== entry.provenance
1134
+ ) {
1135
+ return false;
1136
+ }
1137
+ return (
1138
+ installation.config.version === entry.version &&
1139
+ stringMetadata(installation.metadata.libraryId) === entry.id &&
1140
+ stringMetadata(installation.metadata.libraryVersion) === entry.version &&
1141
+ stringMetadata(installation.metadata.contentSha256) === entry.contentSha256 &&
1142
+ stringMetadata(installation.metadata.sourceCommit) === entry.sourceCommit &&
1143
+ stringMetadata(installation.metadata.provenance) === entry.provenance
1144
+ );
1145
+ }
1146
+
990
1147
  /**
991
1148
  * The connection an installation was enabled with, when the enable-time
992
1149
  * connectionRef fully resolved to one (config.connectionRef is set only by
@@ -1014,6 +1171,17 @@ function installationConnectionRef(
1014
1171
  function dedupeCatalogItems(items: CapabilityCatalogItem[]): CapabilityCatalogItem[] {
1015
1172
  const byId = new Map<string, CapabilityCatalogItem>();
1016
1173
  for (const item of items) {
1174
+ // Curated library entries are runtime-managed and must not be replaced by
1175
+ // stale/manual rows with the same reserved skill id. Other catalog sources
1176
+ // retain the historical last-write-wins behavior below.
1177
+ if (item.kind === "skill" && item.source === "library" && byId.has(item.id)) {
1178
+ byId.set(item.id, item);
1179
+ continue;
1180
+ }
1181
+ const existing = byId.get(item.id);
1182
+ if (existing?.kind === "skill" && existing.source === "library") {
1183
+ continue;
1184
+ }
1017
1185
  byId.set(item.id, item);
1018
1186
  }
1019
1187
  return [...byId.values()];
@@ -1,14 +1,21 @@
1
1
  import type { Settings } from "@opengeni/config";
2
2
  import {
3
+ gitCredentialBindingIdForRepository,
4
+ gitCredentialProviderForRepository,
5
+ defaultRepositoryMountPath,
3
6
  mergeResourceRefs as mergeContractResourceRefs,
4
7
  mergeToolRefs,
8
+ normalizeRepositorySubpath,
9
+ normalizeResourceMountPath,
5
10
  resourceIdentityKey,
11
+ resourceMountPathCollisionKey,
6
12
  ResourceRefConflictError,
13
+ ResourceMountPathError,
7
14
  stableJson,
8
15
  type ResourceRef,
9
16
  type ToolRef,
10
17
  } from "@opengeni/contracts";
11
- import { listGitHubInstallationIdsForWorkspace, requireFile, type Database } from "@opengeni/db";
18
+ import { areGitHubRepositoriesAllowedForWorkspace, requireFile, type Database } from "@opengeni/db";
12
19
  import { HTTPException } from "hono/http-exception";
13
20
 
14
21
  export function validateToolRefs(tools: ToolRef[], settings: Settings): ToolRef[] {
@@ -71,6 +78,7 @@ export function withDefaultEnabledCapabilityMcpTools(
71
78
  export function normalizeResources(resources: ResourceRef[]): ResourceRef[] {
72
79
  const mountPaths = new Map<string, string>();
73
80
  const identities = new Map<string, string>();
81
+ const credentialBindingProviders = new Map<string, string>();
74
82
  const seenResources = new Set<string>();
75
83
  const out: ResourceRef[] = [];
76
84
  for (const resource of resources) {
@@ -93,14 +101,40 @@ export function normalizeResources(resources: ResourceRef[]): ResourceRef[] {
93
101
  throw new HTTPException(422, { message: "repository URL must include owner and repo" });
94
102
  }
95
103
  const repo = parts.join("/");
96
- const mountPath = normalizeMountPath(resource.mountPath ?? `repos/${repo}`);
104
+ const normalizedUri = `https://${url.host.toLowerCase()}/${repo}.git`;
105
+ const mountPath = normalizeMountPath(
106
+ resource.mountPath ?? defaultRepositoryMountPath(normalizedUri),
107
+ );
108
+ const credentialProvider = gitCredentialProviderForRepository(resource);
109
+ const credentialBindingId = gitCredentialBindingIdForRepository(resource, credentialProvider);
110
+ if (
111
+ (resource.credentialBindingId || resource.connectionId || resource.access) &&
112
+ !credentialProvider
113
+ ) {
114
+ throw new HTTPException(422, {
115
+ message: "repository credential bindings and access intent require a Git provider",
116
+ });
117
+ }
118
+ if (credentialProvider && credentialBindingId) {
119
+ const boundProvider = credentialBindingProviders.get(credentialBindingId);
120
+ if (boundProvider && boundProvider !== credentialProvider) {
121
+ throw new HTTPException(422, {
122
+ message: `credential binding ${credentialBindingId} is assigned to multiple Git providers`,
123
+ });
124
+ }
125
+ credentialBindingProviders.set(credentialBindingId, credentialProvider);
126
+ }
97
127
  normalized = {
98
128
  kind: "repository",
99
- uri: `https://${url.hostname.toLowerCase()}/${repo}.git`,
129
+ uri: normalizedUri,
100
130
  ref: resource.ref.trim(),
101
131
  mountPath,
102
- ...(resource.subpath ? { subpath: normalizeMountPath(resource.subpath) } : {}),
132
+ ...(resource.subpath ? { subpath: normalizeRepositorySubpath(resource.subpath) } : {}),
103
133
  ...(resource.provider ? { provider: resource.provider } : {}),
134
+ ...(resource.credentialBindingId
135
+ ? { credentialBindingId: resource.credentialBindingId }
136
+ : {}),
137
+ ...(resource.access ? { access: resource.access } : {}),
104
138
  ...(resource.repositoryId !== undefined ? { repositoryId: resource.repositoryId } : {}),
105
139
  ...(resource.installationId !== undefined
106
140
  ? { installationId: resource.installationId }
@@ -114,14 +148,17 @@ export function normalizeResources(resources: ResourceRef[]): ResourceRef[] {
114
148
  };
115
149
  }
116
150
  const key = stableJson(normalized);
117
- const mounted = normalized.mountPath ? mountPaths.get(normalized.mountPath) : undefined;
151
+ const mountCollisionKey = normalized.mountPath
152
+ ? resourceMountPathCollisionKey(normalized.mountPath)
153
+ : undefined;
154
+ const mounted = mountCollisionKey ? mountPaths.get(mountCollisionKey) : undefined;
118
155
  if (mounted && mounted !== key) {
119
156
  throw new HTTPException(422, {
120
157
  message: `duplicate resource mount path: ${normalized.mountPath}`,
121
158
  });
122
159
  }
123
160
  if (normalized.mountPath) {
124
- mountPaths.set(normalized.mountPath, key);
161
+ mountPaths.set(mountCollisionKey!, key);
125
162
  }
126
163
  const identity = resourceIdentityKey(normalized);
127
164
  const seenIdentity = identities.get(identity);
@@ -153,8 +190,29 @@ export function mergeResourceRefs(
153
190
  }
154
191
  }
155
192
 
193
+ export function validateGitHubRepositorySelectionShapes(resources: ResourceRef[]): number[] {
194
+ const selected = gitHubRepositorySelections(resources);
195
+ if (selected.length === 0) {
196
+ return [];
197
+ }
198
+ return [...new Set(selected.map((item) => item.installationId))];
199
+ }
200
+
201
+ /** @deprecated Use validateGitHubRepositorySelectionShapes for multi-installation sessions. */
156
202
  export function validateGitHubRepositorySelectionShape(resources: ResourceRef[]): number | null {
157
- const selected = resources.flatMap((resource) => {
203
+ const installationIds = validateGitHubRepositorySelectionShapes(resources);
204
+ if (installationIds.length > 1) {
205
+ throw new HTTPException(422, {
206
+ message: "GitHub App repository resources must belong to one installation",
207
+ });
208
+ }
209
+ return installationIds[0] ?? null;
210
+ }
211
+
212
+ function gitHubRepositorySelections(
213
+ resources: ResourceRef[],
214
+ ): Array<{ installationId: number; repositoryId: number }> {
215
+ return resources.flatMap((resource) => {
158
216
  if (resource.kind !== "repository") {
159
217
  return [];
160
218
  }
@@ -180,16 +238,6 @@ export function validateGitHubRepositorySelectionShape(resources: ResourceRef[])
180
238
  }
181
239
  return [{ installationId, repositoryId }];
182
240
  });
183
- if (selected.length === 0) {
184
- return null;
185
- }
186
- const installationId = selected[0]!.installationId;
187
- if (selected.some((item) => item.installationId !== installationId)) {
188
- throw new HTTPException(422, {
189
- message: "GitHub App repository resources must belong to one installation",
190
- });
191
- }
192
- return installationId;
193
241
  }
194
242
 
195
243
  export async function validateGitHubRepositorySelection(
@@ -197,18 +245,28 @@ export async function validateGitHubRepositorySelection(
197
245
  workspaceId: string,
198
246
  resources: ResourceRef[],
199
247
  ): Promise<void> {
200
- const installationId = validateGitHubRepositorySelectionShape(resources);
201
- if (installationId === null) {
248
+ const installationIds = validateGitHubRepositorySelectionShapes(resources);
249
+ if (installationIds.length === 0) {
202
250
  return;
203
251
  }
204
- const linkedInstallationIds = new Set(
205
- await listGitHubInstallationIdsForWorkspace(db, workspaceId),
206
- );
207
- if (!linkedInstallationIds.has(installationId)) {
208
- throw new HTTPException(422, {
209
- message:
210
- "GitHub App repository resources must belong to a GitHub App installation linked to this workspace",
211
- });
252
+ const selections = gitHubRepositorySelections(resources);
253
+ for (const installationId of installationIds) {
254
+ const repositoryIds = selections
255
+ .filter((selection) => selection.installationId === installationId)
256
+ .map((selection) => selection.repositoryId);
257
+ if (
258
+ !(await areGitHubRepositoriesAllowedForWorkspace(
259
+ db,
260
+ workspaceId,
261
+ installationId,
262
+ repositoryIds,
263
+ ))
264
+ ) {
265
+ throw new HTTPException(422, {
266
+ message:
267
+ "GitHub App repository resources must be authorized for a GitHub App installation linked to this workspace",
268
+ });
269
+ }
212
270
  }
213
271
  }
214
272
 
@@ -239,11 +297,12 @@ export async function validateFileResources(
239
297
  }
240
298
 
241
299
  function normalizeMountPath(path: string): string {
242
- const normalized = path.trim().replace(/^\/+|\/+$/g, "");
243
- if (!normalized || normalized.includes("..")) {
300
+ try {
301
+ return normalizeResourceMountPath(path);
302
+ } catch (error) {
303
+ if (!(error instanceof ResourceMountPathError)) throw error;
244
304
  throw new HTTPException(422, { message: `invalid resource mount path: ${path}` });
245
305
  }
246
- return normalized;
247
306
  }
248
307
 
249
308
  function parseResourceUrl(uri: string): URL {