@opengeni/core 0.2.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/README.md +14 -0
- package/dist/index.d.ts +735 -0
- package/dist/index.js +2627 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/src/access/index.ts +186 -0
- package/src/billing/limits.ts +207 -0
- package/src/dependencies.ts +70 -0
- package/src/domain/capabilities.ts +959 -0
- package/src/domain/environments.ts +115 -0
- package/src/domain/packs.ts +241 -0
- package/src/domain/resources.ts +221 -0
- package/src/domain/scheduled-tasks.ts +321 -0
- package/src/domain/sessions.ts +812 -0
- package/src/domain/workspace-members.ts +80 -0
- package/src/index.ts +59 -0
- package/src/managed-auth-type.ts +20 -0
- package/src/sandbox/fleet.ts +460 -0
- package/src/sandbox/routing.ts +127 -0
- package/src/sandbox-types.ts +61 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
|
|
2
|
+
import type { AccessGrant, WorkspaceEnvironment } from "@opengeni/contracts";
|
|
3
|
+
import {
|
|
4
|
+
getWorkspaceEnvironment,
|
|
5
|
+
recordAuditEvent,
|
|
6
|
+
type Database,
|
|
7
|
+
} from "@opengeni/db";
|
|
8
|
+
import { HTTPException } from "hono/http-exception";
|
|
9
|
+
import { requirePermission } from "../access";
|
|
10
|
+
|
|
11
|
+
export const MAX_ENVIRONMENTS_PER_WORKSPACE = 25;
|
|
12
|
+
export const MAX_VARIABLES_PER_ENVIRONMENT = 100;
|
|
13
|
+
|
|
14
|
+
// Names the platform itself injects into sandboxes (sandboxEnvironmentForRun,
|
|
15
|
+
// collectGitIdentityEnvironment) plus loader/startup-injection vectors. These
|
|
16
|
+
// can never be set as workspace environment variables, so the run-scoped
|
|
17
|
+
// GitHub auth block and git identity always win without silent collisions.
|
|
18
|
+
const reservedExactNames = new Set([
|
|
19
|
+
"HOME",
|
|
20
|
+
"PATH",
|
|
21
|
+
"SHELL",
|
|
22
|
+
"USER",
|
|
23
|
+
"LOGNAME",
|
|
24
|
+
"TMPDIR",
|
|
25
|
+
"IFS",
|
|
26
|
+
"ENV",
|
|
27
|
+
"BASH_ENV",
|
|
28
|
+
"NODE_OPTIONS",
|
|
29
|
+
"PYTHONPATH",
|
|
30
|
+
"PYTHONSTARTUP",
|
|
31
|
+
"PERL5OPT",
|
|
32
|
+
"PERL5LIB",
|
|
33
|
+
"GH_TOKEN",
|
|
34
|
+
"GITHUB_TOKEN",
|
|
35
|
+
"GIT_ASKPASS",
|
|
36
|
+
"GIT_TERMINAL_PROMPT",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const reservedPrefixes = [
|
|
40
|
+
"OPENGENI_",
|
|
41
|
+
"GIT_CONFIG_",
|
|
42
|
+
"GIT_AUTHOR_",
|
|
43
|
+
"GIT_COMMITTER_",
|
|
44
|
+
"LD_",
|
|
45
|
+
"DYLD_",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function assertAllowedEnvironmentVariableName(name: string): void {
|
|
49
|
+
if (reservedExactNames.has(name) || reservedPrefixes.some((prefix) => name.startsWith(prefix))) {
|
|
50
|
+
throw new HTTPException(422, { message: `reserved environment variable name: ${name}` });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function requireEnvironmentEncryption(settings: Settings): Uint8Array {
|
|
55
|
+
const key = environmentsEncryptionKeyBytes(settings);
|
|
56
|
+
if (!key) {
|
|
57
|
+
throw new HTTPException(503, { message: "workspace environments require OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY" });
|
|
58
|
+
}
|
|
59
|
+
return key;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function requireEnvironmentForApi(db: Database, workspaceId: string, environmentId: string): Promise<WorkspaceEnvironment> {
|
|
63
|
+
const environment = await getWorkspaceEnvironment(db, workspaceId, environmentId);
|
|
64
|
+
if (!environment) {
|
|
65
|
+
throw new HTTPException(404, { message: "environment not found" });
|
|
66
|
+
}
|
|
67
|
+
return environment;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validates an environment attachment supplied in a request payload (session
|
|
72
|
+
* create, scheduled task create/update, pack enable). Requires the
|
|
73
|
+
* `environments:use` permission unless the attachment was already authorized
|
|
74
|
+
* (pack-installation-inherited attachments), and maps a missing or
|
|
75
|
+
* cross-workspace environment to 422 because the id is payload, not the route
|
|
76
|
+
* target. RLS plus the workspace_id clause make cross-workspace ids
|
|
77
|
+
* indistinguishable from missing ones.
|
|
78
|
+
*/
|
|
79
|
+
export async function validateEnvironmentAttachment(
|
|
80
|
+
deps: { settings: Settings; db: Database },
|
|
81
|
+
grant: AccessGrant,
|
|
82
|
+
workspaceId: string,
|
|
83
|
+
environmentId: string,
|
|
84
|
+
options: { preauthorized?: boolean } = {},
|
|
85
|
+
): Promise<WorkspaceEnvironment> {
|
|
86
|
+
requireEnvironmentEncryption(deps.settings);
|
|
87
|
+
if (!options.preauthorized) {
|
|
88
|
+
requirePermission(grant, "environments:use");
|
|
89
|
+
}
|
|
90
|
+
const environment = await getWorkspaceEnvironment(deps.db, workspaceId, environmentId);
|
|
91
|
+
if (!environment) {
|
|
92
|
+
throw new HTTPException(422, { message: "unknown environmentId" });
|
|
93
|
+
}
|
|
94
|
+
return environment;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function recordEnvironmentAuditEvent(db: Database, input: {
|
|
98
|
+
grant: AccessGrant;
|
|
99
|
+
action: "environment.created" | "environment.updated" | "environment.deleted" | "environment.variable.set" | "environment.variable.deleted";
|
|
100
|
+
environmentId: string;
|
|
101
|
+
variableName?: string;
|
|
102
|
+
}): Promise<void> {
|
|
103
|
+
await recordAuditEvent(db, {
|
|
104
|
+
accountId: input.grant.accountId,
|
|
105
|
+
workspaceId: input.grant.workspaceId,
|
|
106
|
+
subjectId: input.grant.subjectId,
|
|
107
|
+
action: input.action,
|
|
108
|
+
targetType: "workspace_environment",
|
|
109
|
+
targetId: input.environmentId,
|
|
110
|
+
metadata: {
|
|
111
|
+
environmentId: input.environmentId,
|
|
112
|
+
...(input.variableName ? { name: input.variableName } : {}),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CapabilityPack,
|
|
3
|
+
type ScheduledTaskAgentConfig,
|
|
4
|
+
type SocialConnection,
|
|
5
|
+
} from "@opengeni/contracts";
|
|
6
|
+
import { getWorkspacePack, listPackInstallations, listWorkspacePacks, type Database } from "@opengeni/db";
|
|
7
|
+
import { HTTPException } from "hono/http-exception";
|
|
8
|
+
|
|
9
|
+
export const MARKETING_SOCIAL_PACK_ID = "marketing-social-daily-analysis";
|
|
10
|
+
|
|
11
|
+
const marketingSocialPack: CapabilityPack = {
|
|
12
|
+
id: MARKETING_SOCIAL_PACK_ID,
|
|
13
|
+
name: "Marketing social daily analysis",
|
|
14
|
+
description: "Connect social accounts, attach marketing knowledge, and schedule agents to produce daily media performance analysis.",
|
|
15
|
+
role: "marketing",
|
|
16
|
+
category: "social-media",
|
|
17
|
+
version: "0.1.0",
|
|
18
|
+
// Built-in packs deliberately declare no sandboxImage and no skills: the
|
|
19
|
+
// worker's pack-runtime resolution only reads manifest-registered packs
|
|
20
|
+
// (see apps/worker/src/activities/packs.ts), and a test enforces this.
|
|
21
|
+
skills: [],
|
|
22
|
+
tools: [
|
|
23
|
+
{ kind: "mcp", id: "opengeni" },
|
|
24
|
+
{ kind: "mcp", id: "docs" },
|
|
25
|
+
],
|
|
26
|
+
connectors: [
|
|
27
|
+
{
|
|
28
|
+
id: "x",
|
|
29
|
+
name: "X",
|
|
30
|
+
category: "social-media",
|
|
31
|
+
authModel: "oauth2_authorization_code_pkce",
|
|
32
|
+
providers: ["x"],
|
|
33
|
+
scopes: ["tweet.read", "users.read", "offline.access"],
|
|
34
|
+
required: false,
|
|
35
|
+
metadata: {
|
|
36
|
+
docs: "https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "linkedin",
|
|
41
|
+
name: "LinkedIn",
|
|
42
|
+
category: "social-media",
|
|
43
|
+
authModel: "oauth2_authorization_code",
|
|
44
|
+
providers: ["linkedin"],
|
|
45
|
+
scopes: ["r_organization_social", "rw_organization_admin"],
|
|
46
|
+
required: false,
|
|
47
|
+
metadata: {
|
|
48
|
+
docs: "https://learn.microsoft.com/en-us/linkedin/marketing/community-management/community-management-overview",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "instagram",
|
|
53
|
+
name: "Instagram",
|
|
54
|
+
category: "social-media",
|
|
55
|
+
authModel: "oauth2_authorization_code",
|
|
56
|
+
providers: ["instagram", "facebook"],
|
|
57
|
+
scopes: ["instagram_basic", "instagram_manage_insights", "pages_read_engagement", "pages_show_list"],
|
|
58
|
+
required: false,
|
|
59
|
+
metadata: {
|
|
60
|
+
docs: "https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "tiktok",
|
|
65
|
+
name: "TikTok",
|
|
66
|
+
category: "social-media",
|
|
67
|
+
authModel: "oauth2_authorization_code",
|
|
68
|
+
providers: ["tiktok"],
|
|
69
|
+
scopes: ["user.info.basic", "video.list"],
|
|
70
|
+
required: false,
|
|
71
|
+
metadata: {
|
|
72
|
+
docs: "https://developers.tiktok.com/doc/tiktok-api-v2-introduction/",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "youtube",
|
|
77
|
+
name: "YouTube",
|
|
78
|
+
category: "social-media",
|
|
79
|
+
authModel: "oauth2_authorization_code",
|
|
80
|
+
providers: ["youtube"],
|
|
81
|
+
scopes: ["https://www.googleapis.com/auth/youtube.readonly", "https://www.googleapis.com/auth/yt-analytics.readonly"],
|
|
82
|
+
required: false,
|
|
83
|
+
metadata: {
|
|
84
|
+
docs: "https://developers.google.com/youtube/v3",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
knowledge: [
|
|
89
|
+
{
|
|
90
|
+
type: "document_base",
|
|
91
|
+
id: "marketing-playbook",
|
|
92
|
+
name: "Marketing playbook",
|
|
93
|
+
description: "Optional workspace document base with brand voice, campaign calendars, audience research, and reporting rules.",
|
|
94
|
+
required: false,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
scheduledTaskTemplates: [
|
|
98
|
+
{
|
|
99
|
+
id: "daily-social-analysis",
|
|
100
|
+
name: "Daily social analysis",
|
|
101
|
+
description: "Review the latest social posts and account signals every day.",
|
|
102
|
+
defaultSchedule: {
|
|
103
|
+
type: "calendar",
|
|
104
|
+
timeZone: "UTC",
|
|
105
|
+
hour: 9,
|
|
106
|
+
minute: 0,
|
|
107
|
+
},
|
|
108
|
+
defaultRunMode: "new_session_per_run",
|
|
109
|
+
defaultOverlapPolicy: "skip",
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
metadata: {
|
|
113
|
+
skill: "social-media-marketing",
|
|
114
|
+
firstPartyMcpTools: [
|
|
115
|
+
"social_connections_list",
|
|
116
|
+
"social_posts_recent",
|
|
117
|
+
"social_daily_analysis_context",
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const packs = [marketingSocialPack] satisfies CapabilityPack[];
|
|
123
|
+
|
|
124
|
+
export function listCapabilityPacks(): CapabilityPack[] {
|
|
125
|
+
return packs;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getCapabilityPack(packId: string): CapabilityPack | null {
|
|
129
|
+
return packs.find((pack) => pack.id === packId) ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function isBuiltInCapabilityPack(packId: string): boolean {
|
|
133
|
+
return getCapabilityPack(packId) !== null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Built-in packs plus the manifests registered for this workspace. Stored
|
|
138
|
+
* manifests were validated at registration time; rows that no longer parse
|
|
139
|
+
* (for example after a contract tightening) are skipped instead of breaking
|
|
140
|
+
* the whole catalog.
|
|
141
|
+
*/
|
|
142
|
+
export async function listWorkspaceCapabilityPacks(db: Database, workspaceId: string): Promise<CapabilityPack[]> {
|
|
143
|
+
const registered = await listWorkspacePacks(db, workspaceId);
|
|
144
|
+
const builtInIds = new Set(packs.map((pack) => pack.id));
|
|
145
|
+
const registeredPacks = registered
|
|
146
|
+
.filter((registration) => !builtInIds.has(registration.pack.id))
|
|
147
|
+
.flatMap((registration) => {
|
|
148
|
+
const parsed = CapabilityPack.safeParse(registration.pack);
|
|
149
|
+
return parsed.success ? [parsed.data] : [];
|
|
150
|
+
});
|
|
151
|
+
return [...packs, ...registeredPacks];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function resolveCapabilityPack(db: Database, workspaceId: string, packId: string): Promise<CapabilityPack | null> {
|
|
155
|
+
const builtIn = getCapabilityPack(packId);
|
|
156
|
+
if (builtIn) {
|
|
157
|
+
return builtIn;
|
|
158
|
+
}
|
|
159
|
+
const registration = await getWorkspacePack(db, workspaceId, packId);
|
|
160
|
+
if (!registration) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const parsed = CapabilityPack.safeParse(registration.pack);
|
|
164
|
+
return parsed.success ? parsed.data : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* v1 pack-scoped runtime rule: at most one enabled pack per workspace may
|
|
169
|
+
* declare a `sandboxImage` — there is deliberately no image composition or
|
|
170
|
+
* layering. Enforced when a pack is enabled (both the packs endpoint and the
|
|
171
|
+
* generic capability enable path) and re-checked at session start by the
|
|
172
|
+
* worker, which also covers manifests re-registered after enablement.
|
|
173
|
+
*/
|
|
174
|
+
export async function assertPackSandboxImageCompatible(db: Database, workspaceId: string, pack: CapabilityPack): Promise<void> {
|
|
175
|
+
if (!pack.sandboxImage) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const installations = await listPackInstallations(db, workspaceId);
|
|
179
|
+
for (const installation of installations) {
|
|
180
|
+
if (installation.status !== "active" || installation.packId === pack.id) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const other = await resolveCapabilityPack(db, workspaceId, installation.packId);
|
|
184
|
+
if (other?.sandboxImage) {
|
|
185
|
+
throw new HTTPException(409, {
|
|
186
|
+
message: `pack ${pack.id} declares a sandbox image, but enabled pack ${other.id} already declares one; only one enabled pack per workspace may declare sandboxImage — disable ${other.id} first`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function buildMarketingDailyAnalysisAgentConfig(input: {
|
|
193
|
+
connections: SocialConnection[];
|
|
194
|
+
documentBaseIds: string[];
|
|
195
|
+
promptInstructions?: string;
|
|
196
|
+
}): ScheduledTaskAgentConfig {
|
|
197
|
+
const connectionIds = input.connections.map((connection) => connection.id);
|
|
198
|
+
return {
|
|
199
|
+
prompt: marketingDailyAnalysisPrompt({
|
|
200
|
+
connections: input.connections,
|
|
201
|
+
documentBaseIds: input.documentBaseIds,
|
|
202
|
+
...(input.promptInstructions ? { promptInstructions: input.promptInstructions } : {}),
|
|
203
|
+
}),
|
|
204
|
+
resources: [],
|
|
205
|
+
tools: marketingSocialPack.tools,
|
|
206
|
+
metadata: {
|
|
207
|
+
packId: MARKETING_SOCIAL_PACK_ID,
|
|
208
|
+
packTemplateId: "daily-social-analysis",
|
|
209
|
+
socialConnectionIds: connectionIds,
|
|
210
|
+
documentBaseIds: input.documentBaseIds,
|
|
211
|
+
analysisWindowHours: 24,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function marketingDailyAnalysisPrompt(input: {
|
|
217
|
+
connections: SocialConnection[];
|
|
218
|
+
documentBaseIds: string[];
|
|
219
|
+
promptInstructions?: string;
|
|
220
|
+
}): string {
|
|
221
|
+
const connectionLines = input.connections.map((connection) => {
|
|
222
|
+
return `- ${connection.provider}: ${connection.accountHandle} (${connection.id})`;
|
|
223
|
+
}).join("\n");
|
|
224
|
+
const knowledgeLine = input.documentBaseIds.length > 0
|
|
225
|
+
? `Use these document base IDs for brand/campaign knowledge through the docs MCP: ${input.documentBaseIds.join(", ")}.`
|
|
226
|
+
: "No document base IDs were selected; rely only on social context returned by tools.";
|
|
227
|
+
const extra = input.promptInstructions ? `\nAdditional operator instructions:\n${input.promptInstructions.trim()}\n` : "";
|
|
228
|
+
|
|
229
|
+
return [
|
|
230
|
+
"Run the daily social media analysis for the selected accounts.",
|
|
231
|
+
"",
|
|
232
|
+
"First call the OpenGeni MCP tool social_daily_analysis_context with the selected connection IDs and a 24 hour analysis window. Use social_posts_recent only if you need a narrower follow-up query.",
|
|
233
|
+
knowledgeLine,
|
|
234
|
+
"",
|
|
235
|
+
"Selected accounts:",
|
|
236
|
+
connectionLines,
|
|
237
|
+
extra,
|
|
238
|
+
"Produce a concise report with these sections: executive summary, notable account changes, winning posts, underperforming posts, audience and content signals, recommended actions for the next 24 hours, and data gaps.",
|
|
239
|
+
"Use only metrics and posts returned by tools or document search. Do not invent metrics, posts, or account capabilities.",
|
|
240
|
+
].filter(Boolean).join("\n");
|
|
241
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import {
|
|
3
|
+
mergeResourceRefs as mergeContractResourceRefs,
|
|
4
|
+
mergeToolRefs,
|
|
5
|
+
resourceIdentityKey,
|
|
6
|
+
ResourceRefConflictError,
|
|
7
|
+
stableJson,
|
|
8
|
+
type ResourceRef,
|
|
9
|
+
type ToolRef,
|
|
10
|
+
} from "@opengeni/contracts";
|
|
11
|
+
import {
|
|
12
|
+
listGitHubInstallationIdsForWorkspace,
|
|
13
|
+
requireFile,
|
|
14
|
+
type Database,
|
|
15
|
+
} from "@opengeni/db";
|
|
16
|
+
import { HTTPException } from "hono/http-exception";
|
|
17
|
+
|
|
18
|
+
export function validateToolRefs(tools: ToolRef[], settings: Settings): ToolRef[] {
|
|
19
|
+
const mcpServerIds = new Set(settings.mcpServers.map((server) => server.id));
|
|
20
|
+
const selected = new Set<string>();
|
|
21
|
+
const out: ToolRef[] = [];
|
|
22
|
+
for (const tool of tools) {
|
|
23
|
+
if (tool.kind !== "mcp") {
|
|
24
|
+
throw new HTTPException(422, { message: `unsupported tool kind: ${(tool as { kind?: string }).kind}` });
|
|
25
|
+
}
|
|
26
|
+
if (!mcpServerIds.has(tool.id)) {
|
|
27
|
+
throw new HTTPException(422, { message: `unknown MCP server id: ${tool.id}` });
|
|
28
|
+
}
|
|
29
|
+
if (selected.has(tool.id)) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
selected.add(tool.id);
|
|
33
|
+
// Normalize to a bare, STRICT ref: a client-supplied `optional` flag is
|
|
34
|
+
// dropped so an EXPLICITLY-requested tool always fails the turn when its
|
|
35
|
+
// server is unavailable. `optional: true` is set only server-side, at the
|
|
36
|
+
// default-capability auto-attach seam (enabledCapabilityMcpToolRefs).
|
|
37
|
+
out.push({ kind: "mcp", id: tool.id });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type McpSettings = Pick<Settings, "mcpServers">;
|
|
43
|
+
|
|
44
|
+
export function enabledCapabilityMcpToolRefs(settings: McpSettings, runtimeSettings: McpSettings): ToolRef[] {
|
|
45
|
+
const configuredIds = new Set(settings.mcpServers.map((server) => server.id));
|
|
46
|
+
return runtimeSettings.mcpServers
|
|
47
|
+
.filter((server) => !configuredIds.has(server.id))
|
|
48
|
+
// AUTO-ATTACHED (workspace-default) capability servers are marked optional:
|
|
49
|
+
// one of them having a broken/expired credential must SKIP that server, not
|
|
50
|
+
// fail the whole turn before the model runs. The caller only reaches here
|
|
51
|
+
// when the request omitted `tools`; an explicit list is never defaulted.
|
|
52
|
+
.map((server) => ({ kind: "mcp", id: server.id, optional: true }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function withDefaultEnabledCapabilityMcpTools(tools: ToolRef[], settings: McpSettings, runtimeSettings: McpSettings): ToolRef[] {
|
|
56
|
+
return mergeToolRefs(tools, enabledCapabilityMcpToolRefs(settings, runtimeSettings));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeResources(resources: ResourceRef[]): ResourceRef[] {
|
|
60
|
+
const mountPaths = new Map<string, string>();
|
|
61
|
+
const identities = new Map<string, string>();
|
|
62
|
+
const seenResources = new Set<string>();
|
|
63
|
+
const out: ResourceRef[] = [];
|
|
64
|
+
for (const resource of resources) {
|
|
65
|
+
let normalized: ResourceRef;
|
|
66
|
+
if (resource.kind === "file") {
|
|
67
|
+
const mountPath = normalizeMountPath(resource.mountPath ?? `files/${resource.fileId}`);
|
|
68
|
+
normalized = {
|
|
69
|
+
kind: "file",
|
|
70
|
+
fileId: resource.fileId,
|
|
71
|
+
mountPath,
|
|
72
|
+
};
|
|
73
|
+
} else {
|
|
74
|
+
const url = parseResourceUrl(resource.uri);
|
|
75
|
+
if (url.protocol !== "https:" || !url.hostname) {
|
|
76
|
+
throw new HTTPException(422, { message: "repository resources must use HTTPS Git URLs" });
|
|
77
|
+
}
|
|
78
|
+
const path = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
|
|
79
|
+
const parts = path.split("/").filter(Boolean);
|
|
80
|
+
if (parts.length < 2) {
|
|
81
|
+
throw new HTTPException(422, { message: "repository URL must include owner and repo" });
|
|
82
|
+
}
|
|
83
|
+
const repo = parts.join("/");
|
|
84
|
+
const mountPath = normalizeMountPath(resource.mountPath ?? `repos/${repo}`);
|
|
85
|
+
normalized = {
|
|
86
|
+
kind: "repository",
|
|
87
|
+
uri: `https://${url.hostname.toLowerCase()}/${repo}.git`,
|
|
88
|
+
ref: resource.ref.trim(),
|
|
89
|
+
mountPath,
|
|
90
|
+
...(resource.subpath ? { subpath: normalizeMountPath(resource.subpath) } : {}),
|
|
91
|
+
...(resource.githubInstallationId ? { githubInstallationId: resource.githubInstallationId } : {}),
|
|
92
|
+
...(resource.githubRepositoryId ? { githubRepositoryId: resource.githubRepositoryId } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const key = stableJson(normalized);
|
|
96
|
+
const mounted = normalized.mountPath ? mountPaths.get(normalized.mountPath) : undefined;
|
|
97
|
+
if (mounted && mounted !== key) {
|
|
98
|
+
throw new HTTPException(422, { message: `duplicate resource mount path: ${normalized.mountPath}` });
|
|
99
|
+
}
|
|
100
|
+
if (normalized.mountPath) {
|
|
101
|
+
mountPaths.set(normalized.mountPath, key);
|
|
102
|
+
}
|
|
103
|
+
const identity = resourceIdentityKey(normalized);
|
|
104
|
+
const seenIdentity = identities.get(identity);
|
|
105
|
+
if (seenIdentity && seenIdentity !== key) {
|
|
106
|
+
throw new HTTPException(422, { message: `duplicate resource with different settings: ${identity}` });
|
|
107
|
+
}
|
|
108
|
+
identities.set(identity, key);
|
|
109
|
+
if (!seenResources.has(key)) {
|
|
110
|
+
seenResources.add(key);
|
|
111
|
+
out.push(normalized);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function mergeResourceRefs(existing: ResourceRef[], additions: ResourceRef[]): ResourceRef[] {
|
|
118
|
+
try {
|
|
119
|
+
return mergeContractResourceRefs(existing, additions, { rejectConflicts: true });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error instanceof ResourceRefConflictError) {
|
|
122
|
+
throw new HTTPException(422, { message: error.message });
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function validateGitHubRepositorySelectionShape(resources: ResourceRef[]): number | null {
|
|
129
|
+
const selected = resources.flatMap((resource) => {
|
|
130
|
+
if (resource.kind !== "repository") {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const installationRaw = resource.githubInstallationId;
|
|
134
|
+
const repositoryRaw = resource.githubRepositoryId;
|
|
135
|
+
if (installationRaw === null && repositoryRaw === null) {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
if (installationRaw === undefined && repositoryRaw === undefined) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
const installationId = positiveInteger(installationRaw);
|
|
142
|
+
const repositoryId = positiveInteger(repositoryRaw);
|
|
143
|
+
if (!installationId || !repositoryId) {
|
|
144
|
+
throw new HTTPException(422, {
|
|
145
|
+
message: "GitHub App repository resources require positive github_installation_id and github_repository_id",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return [{ installationId, repositoryId }];
|
|
149
|
+
});
|
|
150
|
+
if (selected.length === 0) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const installationId = selected[0]!.installationId;
|
|
154
|
+
if (selected.some((item) => item.installationId !== installationId)) {
|
|
155
|
+
throw new HTTPException(422, {
|
|
156
|
+
message: "GitHub App repository resources must belong to one installation",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return installationId;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function validateGitHubRepositorySelection(db: Database, workspaceId: string, resources: ResourceRef[]): Promise<void> {
|
|
163
|
+
const installationId = validateGitHubRepositorySelectionShape(resources);
|
|
164
|
+
if (installationId === null) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const linkedInstallationIds = new Set(await listGitHubInstallationIdsForWorkspace(db, workspaceId));
|
|
168
|
+
if (!linkedInstallationIds.has(installationId)) {
|
|
169
|
+
throw new HTTPException(422, {
|
|
170
|
+
message: "GitHub App repository resources must belong to a GitHub App installation linked to this workspace",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function validateFileResources(db: Database, workspaceId: string, resources: ResourceRef[]): Promise<void> {
|
|
176
|
+
const fileIds = new Set<string>();
|
|
177
|
+
for (const resource of resources) {
|
|
178
|
+
if (resource.kind !== "file") {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (fileIds.has(resource.fileId)) {
|
|
182
|
+
throw new HTTPException(422, { message: `duplicate file resource: ${resource.fileId}` });
|
|
183
|
+
}
|
|
184
|
+
fileIds.add(resource.fileId);
|
|
185
|
+
const file = await requireFile(db, workspaceId, resource.fileId).catch(() => null);
|
|
186
|
+
if (!file) {
|
|
187
|
+
throw new HTTPException(422, { message: `unknown file resource: ${resource.fileId}` });
|
|
188
|
+
}
|
|
189
|
+
if (file.status !== "ready") {
|
|
190
|
+
throw new HTTPException(422, { message: `file resource ${resource.fileId} is ${file.status}` });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function normalizeMountPath(path: string): string {
|
|
196
|
+
const normalized = path.trim().replace(/^\/+|\/+$/g, "");
|
|
197
|
+
if (!normalized || normalized.includes("..")) {
|
|
198
|
+
throw new HTTPException(422, { message: `invalid resource mount path: ${path}` });
|
|
199
|
+
}
|
|
200
|
+
return normalized;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseResourceUrl(uri: string): URL {
|
|
204
|
+
try {
|
|
205
|
+
return new URL(uri);
|
|
206
|
+
} catch {
|
|
207
|
+
throw new HTTPException(422, { message: "repository resources must use valid URLs" });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function positiveInteger(value: unknown): number | null {
|
|
212
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
if (typeof value === "string" && /^\d+$/.test(value) && Number(value) > 0) {
|
|
216
|
+
return Number(value);
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export { mergeToolRefs, stableJson };
|