@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.
- package/dist/index.d.ts +103 -138
- package/dist/index.js +792 -154
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/access/index.ts +4 -0
- package/src/application/session-commands.ts +190 -38
- package/src/billing/limits.ts +20 -1
- package/src/dependencies.ts +26 -2
- package/src/domain/capabilities.ts +171 -3
- package/src/domain/resources.ts +89 -30
- package/src/domain/sessions.ts +409 -93
- package/src/index.ts +1 -0
- package/src/sandbox/fleet.ts +2 -2
- package/src/sandbox/routing.ts +1 -1
- package/src/session-authorization.ts +208 -0
|
@@ -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
|
-
|
|
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 ${
|
|
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()];
|
package/src/domain/resources.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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:
|
|
129
|
+
uri: normalizedUri,
|
|
100
130
|
ref: resource.ref.trim(),
|
|
101
131
|
mountPath,
|
|
102
|
-
...(resource.subpath ? { 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
|
|
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(
|
|
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
|
|
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
|
|
201
|
-
if (
|
|
248
|
+
const installationIds = validateGitHubRepositorySelectionShapes(resources);
|
|
249
|
+
if (installationIds.length === 0) {
|
|
202
250
|
return;
|
|
203
251
|
}
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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 {
|