History

Update tool source auth and runtime loading

Rhys Sullivan committed 8 months ago

Commit 5e836b31 parent 5a76db716 files · +387 −101Browse files

Unified · Split

16 files
  1. executor/packages/core/src/function-builders.ts +18 −0
  2. executor/packages/core/src/managed-runtime.ts +1 −1
  3. executor/packages/core/src/managed/runtime-bootstrap.ts +1 −7
  4. executor/packages/core/src/openapi-compaction.ts +8 −1
  5. executor/packages/core/src/openapi-typing.test.ts +186 −0
  6. executor/packages/core/src/openapi/schema-hints.ts +80 −10
  7. executor/packages/core/src/openapi/tool-builder.ts +2 −2
  8. executor/packages/core/src/tool-source-loaders/graphql-loader.ts +2 −2
  9. executor/packages/core/src/tool-source-loaders/mcp-loader.ts +2 −2
  10. executor/packages/core/src/tool-source-loaders/openapi-loader.ts +2 −2
  11. executor/packages/core/src/tool-sources.ts +7 −14
  12. executor/packages/core/src/tool/source-auth.test.ts +30 −21
  13. executor/packages/core/src/tool/source-auth.ts +39 −33
  14. executor/packages/core/src/tool/source-serialization.ts +0 −1
  15. executor/packages/core/src/tool/source-types.ts +3 −3
  16. executor/packages/core/src/types.ts +6 −2
executor/packages/core/src/function-builders.ts+18−0
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
4 customMutation as convexCustomMutation,4 customMutation as convexCustomMutation,
5 customQuery as convexCustomQuery,5 customQuery as convexCustomQuery,
6} from "convex-helpers/server/customFunctions";6} from "convex-helpers/server/customFunctions";
7import type { Id } from "../../database/convex/_generated/dataModel.d.ts";
7import { internal as internalApi } from "../../database/convex/_generated/api";8import { internal as internalApi } from "../../database/convex/_generated/api";
8import { action, internalQuery, mutation, query } from "../../database/convex/_generated/server";9import { action, internalQuery, mutation, query } from "../../database/convex/_generated/server";
9import {10import {
@@ -14,6 +15,16 @@ import { · 4 unmodified lines
14 resolveAccountForRequest,15 resolveAccountForRequest,
15} from "./identity";16} from "./identity";
16 17
18export type WorkspaceActionContext = {
19 workspaceId: Id<"workspaces">;
20 accountId: Id<"accounts">;
21 provider: string;
22 providerAccountId: string;
23 role: string;
24 sessionId?: string;
25 clientId: "web" | "mcp";
26};
27
17export type OpenApiMethod = "GET" | "POST";28export type OpenApiMethod = "GET" | "POST";
18 29
19type QueryMethodOptions = {30type QueryMethodOptions = {
@@ -71,6 +82,11 @@ async function requireAccountFromSession( · 51 unmodified lines
71 return account;82 return account;
72}83}
73 84
85function clientIdForSession(sessionId?: string): "web" | "mcp" {
86 const normalizedSessionId = sessionId?.trim() ?? "";
87 return normalizedSessionId.startsWith("mcp_") ? "mcp" : "web";
88}
89
74export const customQuery = convexCustomQuery(query, {90export const customQuery = convexCustomQuery(query, {
75 args: {},91 args: {},
76 input: async (_ctx, _args, _options: QueryMethodOptions) => ({92 input: async (_ctx, _args, _options: QueryMethodOptions) => ({
@@ -112,6 +128,8 @@ export const workspaceAction = convexCustomAction(action, { · 35 unmodified lines
112 ctx: {128 ctx: {
113 ...access,129 ...access,
114 workspaceId: args.workspaceId,130 workspaceId: args.workspaceId,
131 sessionId: args.sessionId,
132 clientId: clientIdForSession(args.sessionId),
115 },133 },
116 args: {},134 args: {},
117 };135 };
executor/packages/core/src/managed-runtime.ts+1−1
@@ -9,7 +9,7 @@ import { runProcess } from "./managed/runtime-process"; · 8 unmodified lines
9 9
10const managedAnonymousAuthEnvFileName = "managed-anonymous-auth.json";10const managedAnonymousAuthEnvFileName = "managed-anonymous-auth.json";
11 11
12type ManagedAnonymousAuthEnv = {12export type ManagedAnonymousAuthEnv = {
13 ANONYMOUS_AUTH_PRIVATE_KEY_PEM: string;13 ANONYMOUS_AUTH_PRIVATE_KEY_PEM: string;
14 ANONYMOUS_AUTH_PUBLIC_KEY_PEM: string;14 ANONYMOUS_AUTH_PUBLIC_KEY_PEM: string;
15 MCP_API_KEY_SECRET: string;15 MCP_API_KEY_SECRET: string;
executor/packages/core/src/managed/runtime-bootstrap.ts+1−7
@@ -4,7 +4,7 @@ import path from "node:path"; · 3 unmodified lines
4import { generateKeyPairSync } from "node:crypto";4import { generateKeyPairSync } from "node:crypto";
5import { z } from "zod";5import { z } from "zod";
6 6
7import type { ManagedRuntimeInfo } from "../managed-runtime";7import type { ManagedAnonymousAuthEnv, ManagedRuntimeInfo } from "../managed-runtime";
8import { managedRuntimeVersions, pathExists } from "./runtime-info";8import { managedRuntimeVersions, pathExists } from "./runtime-info";
9import { ensureConvexCliRuntime } from "./runtime-installation";9import { ensureConvexCliRuntime } from "./runtime-installation";
10import { runProcess } from "./runtime-process";10import { runProcess } from "./runtime-process";
@@ -40,12 +40,6 @@ const BOOTSTRAP_REQUIRED_DEPENDENCIES: BootstrapDependency[] = [ · 29 unmodified lines
40 40
41const managedAnonymousAuthEnvFileName = "managed-anonymous-auth.json";41const managedAnonymousAuthEnvFileName = "managed-anonymous-auth.json";
42 42
43type ManagedAnonymousAuthEnv = {
44 ANONYMOUS_AUTH_PRIVATE_KEY_PEM: string;
45 ANONYMOUS_AUTH_PUBLIC_KEY_PEM: string;
46 MCP_API_KEY_SECRET: string;
47};
48
49function trimEnv(name: string): string | null {43function trimEnv(name: string): string | null {
50 const value = process.env[name]?.trim();44 const value = process.env[name]?.trim();
51 return value && value.length > 0 ? value : null;45 return value && value.length > 0 ? value : null;
executor/packages/core/src/openapi-compaction.ts+8−1
@@ -93,11 +93,18 @@ export function compactOpenApiPaths( · 92 unmodified lines
93 ? toRecordOrEmpty(parsed.data.examples)93 ? toRecordOrEmpty(parsed.data.examples)
94 : undefined;94 : undefined;
95 95
96 const parameterSchema = includeParameterSchemas
97 ? parameterSchemaFromEntry(entry)
98 : {};
99 const resolvedParameterSchema = includeParameterSchemas && resolveSchemaRefs
100 ? resolveSchemaRef(parameterSchema, compSchemas)
101 : parameterSchema;
102
96 return {103 return {
97 name: parsed.data.name,104 name: parsed.data.name,
98 in: location,105 in: location,
99 required: location === "path" ? true : (parsed.data.required ?? false),106 required: location === "path" ? true : (parsed.data.required ?? false),
100 schema: includeParameterSchemas ? parameterSchemaFromEntry(entry) : {},107 schema: resolvedParameterSchema,
101 ...(description.length > 0 ? { description } : {}),108 ...(description.length > 0 ? { description } : {}),
102 ...(parsed.data.deprecated !== undefined ? { deprecated: parsed.data.deprecated } : {}),109 ...(parsed.data.deprecated !== undefined ? { deprecated: parsed.data.deprecated } : {}),
103 ...(style.length > 0 ? { style } : {}),110 ...(style.length > 0 ? { style } : {}),
executor/packages/core/src/openapi-typing.test.ts+186−0
@@ -196,6 +196,86 @@ describe("OpenAPI schema-first typing", () => { · 195 unmodified lines
196 expect(fieldsParam?.examples).toEqual({ minimal: { value: ["name"] } });196 expect(fieldsParam?.examples).toEqual({ minimal: { value: ["name"] } });
197 });197 });
198 198
199 test("full profile resolves component refs inside parameter schemas", async () => {
200 const spec: Record<string, unknown> = {
201 openapi: "3.0.3",
202 info: { title: "Include API", version: "1.0.0" },
203 servers: [{ url: "https://api.example.com" }],
204 components: {
205 schemas: {
206 IncludeEnum: {
207 type: "string",
208 enum: ["message.input_image.image_url", "message.output_text.logprobs"],
209 },
210 },
211 },
212 paths: {
213 "/conversations/{conversation_id}/items/{item_id}": {
214 get: {
215 operationId: "getConversationItem",
216 tags: ["conversations"],
217 parameters: [
218 { name: "conversation_id", in: "path", required: true, schema: { type: "string" } },
219 { name: "item_id", in: "path", required: true, schema: { type: "string" } },
220 {
221 name: "include",
222 in: "query",
223 schema: {
224 type: "array",
225 items: { $ref: "#/components/schemas/IncludeEnum" },
226 },
227 },
228 ],
229 responses: {
230 "200": {
231 description: "ok",
232 content: {
233 "application/json": {
234 schema: {
235 type: "object",
236 properties: {
237 id: { type: "string" },
238 },
239 },
240 },
241 },
242 },
243 },
244 },
245 },
246 },
247 };
248
249 const prepared = await prepareOpenApiSpec(spec, "include", { includeDts: false, profile: "full" });
250 const tools = buildOpenApiToolsFromPrepared(
251 { type: "openapi", name: "include", spec, baseUrl: "https://api.example.com" },
252 prepared,
253 );
254
255 const getConversationItem = tools.find((tool) => tool.typing?.typedRef?.operationId === "getConversationItem");
256 expect(getConversationItem).toBeDefined();
257
258 const inputSchema = (getConversationItem!.typing?.inputSchema ?? {}) as {
259 properties?: Record<string, {
260 properties?: Record<string, Record<string, unknown>>;
261 }>;
262 };
263 const includeSchema = inputSchema.properties?.query?.properties?.include as {
264 type?: string;
265 items?: {
266 type?: string;
267 enum?: unknown[];
268 };
269 } | undefined;
270
271 expect(includeSchema?.type).toBe("array");
272 expect(includeSchema?.items?.type).toBe("string");
273 expect(includeSchema?.items?.enum).toEqual([
274 "message.input_image.image_url",
275 "message.output_text.logprobs",
276 ]);
277 });
278
199 test("full profile with dts sets typedRef for OpenAPI operations", async () => {279 test("full profile with dts sets typedRef for OpenAPI operations", async () => {
200 const spec = makeLargeSpec(3);280 const spec = makeLargeSpec(3);
201 const prepared = await prepareOpenApiSpec(spec, "large", { includeDts: true, profile: "full" });281 const prepared = await prepareOpenApiSpec(spec, "large", { includeDts: true, profile: "full" });
@@ -288,6 +368,112 @@ describe("OpenAPI schema-first typing", () => { · 86 unmodified lines
288 expect(prepared.refHintTable?.DeepMeta).toContain("field_0");368 expect(prepared.refHintTable?.DeepMeta).toContain("field_0");
289 });369 });
290 370
371 test("full profile resolves nested oneOf component refs for output schemas", async () => {
372 const spec: Record<string, unknown> = {
373 openapi: "3.0.3",
374 info: { title: "Conversation API", version: "1.0.0" },
375 servers: [{ url: "https://api.example.com" }],
376 components: {
377 schemas: {
378 InputTextPart: {
379 type: "object",
380 properties: {
381 type: { type: "string", enum: ["input_text"] },
382 text: { type: "string" },
383 },
384 required: ["type", "text"],
385 },
386 OutputTextPart: {
387 type: "object",
388 properties: {
389 type: { type: "string", enum: ["output_text"] },
390 text: { type: "string" },
391 },
392 required: ["type", "text"],
393 },
394 MessageItem: {
395 type: "object",
396 properties: {
397 type: { type: "string", enum: ["message"] },
398 id: { type: "string" },
399 content: {
400 type: "array",
401 items: {
402 oneOf: [
403 { $ref: "#/components/schemas/InputTextPart" },
404 { $ref: "#/components/schemas/OutputTextPart" },
405 ],
406 },
407 },
408 },
409 required: ["type", "id", "content"],
410 },
411 FunctionCallItem: {
412 type: "object",
413 properties: {
414 type: { type: "string", enum: ["function_call"] },
415 id: { type: "string" },
416 call_id: { type: "string" },
417 },
418 required: ["type", "id", "call_id"],
419 },
420 },
421 },
422 paths: {
423 "/conversations/{conversation_id}/items/{item_id}": {
424 get: {
425 operationId: "getConversationItem",
426 tags: ["conversations"],
427 parameters: [
428 { name: "conversation_id", in: "path", required: true, schema: { type: "string" } },
429 { name: "item_id", in: "path", required: true, schema: { type: "string" } },
430 ],
431 responses: {
432 "200": {
433 description: "ok",
434 content: {
435 "application/json": {
436 schema: {
437 oneOf: [
438 { $ref: "#/components/schemas/MessageItem" },
439 { $ref: "#/components/schemas/FunctionCallItem" },
440 ],
441 },
442 },
443 },
444 },
445 },
446 },
447 },
448 },
449 };
450
451 const prepared = await prepareOpenApiSpec(spec, "conversation", { includeDts: false, profile: "full" });
452 const tools = buildOpenApiToolsFromPrepared(
453 { type: "openapi", name: "conversation", spec, baseUrl: "https://api.example.com" },
454 prepared,
455 );
456
457 const getConversationItem = tools.find((tool) => tool.typing?.typedRef?.operationId === "getConversationItem");
458 expect(getConversationItem).toBeDefined();
459
460 const outputSchema = (getConversationItem!.typing?.outputSchema ?? {}) as {
461 oneOf?: Array<Record<string, unknown>>;
462 };
463 expect(Array.isArray(outputSchema.oneOf)).toBe(true);
464
465 const variants = outputSchema.oneOf ?? [];
466 const variantPropertyLists = variants.map((variant) => Object.keys((variant.properties as Record<string, unknown>) ?? {}));
467 expect(variantPropertyLists.some((keys) => keys.includes("content"))).toBe(true);
468 expect(variantPropertyLists.some((keys) => keys.includes("call_id"))).toBe(true);
469
470 const messageVariant = variants.find((variant) => Object.keys((variant.properties as Record<string, unknown>) ?? {}).includes("content"));
471 const messageProperties = (messageVariant?.properties as Record<string, unknown>) ?? {};
472 const contentProperty = (messageProperties.content as Record<string, unknown>) ?? {};
473 const itemOneOf = (((contentProperty.items as Record<string, unknown>)?.oneOf) ?? []) as Array<Record<string, unknown>>;
474 expect(itemOneOf.some((entry) => Object.keys((entry.properties as Record<string, unknown>) ?? {}).includes("text"))).toBe(true);
475 });
476
291 test("OpenAPI input hints compact allOf object intersections", async () => {477 test("OpenAPI input hints compact allOf object intersections", async () => {
292 const spec: Record<string, unknown> = {478 const spec: Record<string, unknown> = {
293 openapi: "3.0.3",479 openapi: "3.0.3",
executor/packages/core/src/openapi/schema-hints.ts+80−10
@@ -7,6 +7,22 @@ function toRecordOrEmpty(value: unknown): Record<string, unknown> { · 6 unmodified lines
7}7}
8 8
9const COMPONENT_REF_INLINE_DEPTH = 2;9const COMPONENT_REF_INLINE_DEPTH = 2;
10const MAX_SCHEMA_REF_RESOLVE_DEPTH = 10;
11const schemaChildKeys = [
12 "items",
13 "additionalItems",
14 "additionalProperties",
15 "contains",
16 "not",
17 "if",
18 "then",
19 "else",
20 "propertyNames",
21 "unevaluatedProperties",
22 "unevaluatedItems",
23] as const;
24const schemaArrayChildKeys = ["allOf", "anyOf", "oneOf", "prefixItems"] as const;
25const schemaMapChildKeys = ["properties", "patternProperties", "dependentSchemas", "definitions", "$defs"] as const;
10const openApiParameterMetadataSchema = z.object({26const openApiParameterMetadataSchema = z.object({
11 type: z.string().optional(),27 type: z.string().optional(),
12 enum: z.array(z.unknown()).optional(),28 enum: z.array(z.unknown()).optional(),
@@ -101,18 +117,72 @@ export function resolveSchemaRef( · 88 unmodified lines
101 schema: Record<string, unknown>,117 schema: Record<string, unknown>,
102 componentSchemas: Record<string, unknown>,118 componentSchemas: Record<string, unknown>,
103): Record<string, unknown> {119): Record<string, unknown> {
104 const ref = typeof schema.$ref === "string" ? schema.$ref : "";
105 const prefix = "#/components/schemas/";120 const prefix = "#/components/schemas/";
106 if (!ref.startsWith(prefix)) {
107 return schema;
108 }
109 121
110 const key = ref.slice(prefix.length);122 const resolveNode = (
111 const resolved = toRecordOrEmpty(componentSchemas[key]);123 value: unknown,
112 if (Object.keys(resolved).length === 0) {124 depth: number,
113 return schema;125 activeRefs: Set<string>,
114 }126 ): unknown => {
115 return resolved;127 if (!value || typeof value !== "object") {
128 return value;
129 }
130 if (depth > MAX_SCHEMA_REF_RESOLVE_DEPTH) {
131 return value;
132 }
133
134 const shape = toRecordOrEmpty(value);
135 const ref = typeof shape.$ref === "string" ? shape.$ref : "";
136 if (ref.startsWith(prefix) && !activeRefs.has(ref)) {
137 const key = ref.slice(prefix.length);
138 const resolved = toRecordOrEmpty(componentSchemas[key]);
139 if (Object.keys(resolved).length > 0) {
140 const siblingEntries = Object.entries(shape).filter(([entryKey]) => entryKey !== "$ref");
141 const merged = siblingEntries.length > 0
142 ? {
143 ...resolved,
144 ...Object.fromEntries(siblingEntries),
145 }
146 : resolved;
147 const nextRefs = new Set(activeRefs);
148 nextRefs.add(ref);
149 return resolveNode(merged, depth + 1, nextRefs);
150 }
151 }
152
153 const nextShape: Record<string, unknown> = { ...shape };
154
155 for (const key of schemaChildKeys) {
156 const child = shape[key];
157 if (child && typeof child === "object") {
158 nextShape[key] = resolveNode(child, depth + 1, activeRefs);
159 }
160 }
161
162 for (const key of schemaArrayChildKeys) {
163 const child = shape[key];
164 if (Array.isArray(child)) {
165 nextShape[key] = child.map((entry) => resolveNode(entry, depth + 1, activeRefs));
166 }
167 }
168
169 for (const key of schemaMapChildKeys) {
170 const childMap = toRecordOrEmpty(shape[key]);
171 if (Object.keys(childMap).length === 0) {
172 continue;
173 }
174 nextShape[key] = Object.fromEntries(
175 Object.entries(childMap).map(([childKey, childValue]) => [
176 childKey,
177 resolveNode(childValue, depth + 1, activeRefs),
178 ]),
179 );
180 }
181
182 return nextShape;
183 };
184
185 return toRecordOrEmpty(resolveNode(schema, 0, new Set()));
116}186}
117 187
118export function resolveRequestBodyRef(188export function resolveRequestBodyRef(
executor/packages/core/src/openapi/tool-builder.ts+2−2
@@ -8,7 +8,7 @@ import { · 7 unmodified lines
8 responseTypeHintFromSchema,8 responseTypeHintFromSchema,
9} from "./schema-hints";9} from "./schema-hints";
10import { buildOpenApiToolPath } from "./tool-path";10import { buildOpenApiToolPath } from "./tool-path";
11import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";11import { buildCredentialSpec, getCredentialSourceKey } from "../tool/source-auth";
12import { executeOpenApiRequest } from "../tool/source-execution";12import { executeOpenApiRequest } from "../tool/source-execution";
13import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "../tool/source-types";13import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "../tool/source-types";
14import type { ToolDefinition } from "../types";14import type { ToolDefinition } from "../types";
@@ -127,7 +127,7 @@ export function buildOpenApiToolsFromPrepared( · 112 unmodified lines
127 }127 }
128 128
129 const effectiveAuth = config.auth ?? prepared.inferredAuth;129 const effectiveAuth = config.auth ?? prepared.inferredAuth;
130 const authHeaders = buildStaticAuthHeaders(effectiveAuth);130 const authHeaders = {};
131 const sourceLabel = `openapi:${config.name}`;131 const sourceLabel = `openapi:${config.name}`;
132 const credentialSourceKey = getCredentialSourceKey(config);132 const credentialSourceKey = getCredentialSourceKey(config);
133 const credentialSpec = buildCredentialSpec(credentialSourceKey, effectiveAuth);133 const credentialSpec = buildCredentialSpec(credentialSourceKey, effectiveAuth);
executor/packages/core/src/tool-source-loaders/graphql-loader.ts+2−2
@@ -16,7 +16,7 @@ import { · 15 unmodified lines
16 executeGraphqlRequest,16 executeGraphqlRequest,
17 type GraphqlExecutionEnvelope,17 type GraphqlExecutionEnvelope,
18} from "../tool/source-execution";18} from "../tool/source-execution";
19import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";19import { buildCredentialSpec, getCredentialSourceKey } from "../tool/source-auth";
20import { sanitizeSegment } from "../tool/path-utils";20import { sanitizeSegment } from "../tool/path-utils";
21import type { GraphqlToolSourceConfig } from "../tool/source-types";21import type { GraphqlToolSourceConfig } from "../tool/source-types";
22import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";22import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
@@ -388,7 +388,7 @@ function toGraphqlEnvelope(value: unknown): GraphqlExecutionEnvelope { · 365 unmodified lines
388}388}
389 389
390export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> {390export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> {
391 const authHeaders = buildStaticAuthHeaders(config.auth);391 const authHeaders = {};
392 const sourceKey = `graphql:${config.name}`;392 const sourceKey = `graphql:${config.name}`;
393 const credentialSpec = buildCredentialSpec(getCredentialSourceKey(config), config.auth);393 const credentialSpec = buildCredentialSpec(getCredentialSourceKey(config), config.auth);
394 const sourceName = sanitizeSegment(config.name);394 const sourceName = sanitizeSegment(config.name);
executor/packages/core/src/tool-source-loaders/mcp-loader.ts+2−2
@@ -1,6 +1,6 @@
1import { z } from "zod";1import { z } from "zod";
2import { connectMcp, extractMcpResult } from "../mcp-runtime";2import { connectMcp, extractMcpResult } from "../mcp-runtime";
3import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";3import { buildCredentialSpec, getCredentialSourceKey } from "../tool/source-auth";
4import { callMcpToolWithReconnect } from "../tool/source-execution";4import { callMcpToolWithReconnect } from "../tool/source-execution";
5import { sanitizeSegment } from "../tool/path-utils";5import { sanitizeSegment } from "../tool/path-utils";
6import type { McpToolSourceConfig } from "../tool/source-types";6import type { McpToolSourceConfig } from "../tool/source-types";
@@ -40,7 +40,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 33 unmodified lines
40 Object.entries(config.queryParams).map(([key, value]) => [key, String(value)]),40 Object.entries(config.queryParams).map(([key, value]) => [key, String(value)]),
41 )41 )
42 : undefined;42 : undefined;
43 const authHeaders = buildStaticAuthHeaders(config.auth);43 const authHeaders = {};
44 const discoveryHeaders = {44 const discoveryHeaders = {
45 ...authHeaders,45 ...authHeaders,
46 ...(config.discoveryHeaders ?? {}),46 ...(config.discoveryHeaders ?? {}),
executor/packages/core/src/tool-source-loaders/openapi-loader.ts+2−2
@@ -1,6 +1,6 @@
1import { z } from "zod";1import { z } from "zod";
2import { buildOpenApiToolsFromPrepared } from "../openapi/tool-builder";2import { buildOpenApiToolsFromPrepared } from "../openapi/tool-builder";
3import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";3import { buildCredentialSpec, getCredentialSourceKey } from "../tool/source-auth";
4import {4import {
5 buildPostmanToolPath,5 buildPostmanToolPath,
6 extractPostmanBody,6 extractPostmanBody,
@@ -119,7 +119,7 @@ async function loadPostmanCollectionTools( · 112 unmodified lines
119 }119 }
120 120
121 const sourceLabel = `catalog:${config.name}`;121 const sourceLabel = `catalog:${config.name}`;
122 const authHeaders = buildStaticAuthHeaders(config.auth);122 const authHeaders = {};
123 const credentialSourceKey = getCredentialSourceKey(config);123 const credentialSourceKey = getCredentialSourceKey(config);
124 const credentialSpec = buildCredentialSpec(credentialSourceKey, config.auth);124 const credentialSpec = buildCredentialSpec(credentialSourceKey, config.auth);
125 const readMethods = new Set(["get", "head", "options"]);125 const readMethods = new Set(["get", "head", "options"]);
executor/packages/core/src/tool-sources.ts+7−14
@@ -26,15 +26,6 @@ export { parseGraphqlOperationPaths } from "./graphql/operation-paths"; · 25 unmodified lines
26export { rehydrateTools, serializeTools, type SerializedTool } from "./tool/source-serialization";26export { rehydrateTools, serializeTools, type SerializedTool } from "./tool/source-serialization";
27export { buildOpenApiToolsFromPrepared } from "./openapi/tool-builder";27export { buildOpenApiToolsFromPrepared } from "./openapi/tool-builder";
28 28
29export interface CompiledToolSourceArtifact {
30 version: "v1";
31 sourceType: ExternalToolSourceConfig["type"];
32 sourceName: string;
33 openApiSourceKey?: string;
34 openApiRefHintTable?: Record<string, string>;
35 tools: SerializedTool[];
36}
37
38const compiledToolSourceArtifactSchema = z.object({29const compiledToolSourceArtifactSchema = z.object({
39 version: z.literal("v1"),30 version: z.literal("v1"),
40 sourceType: z.enum(["mcp", "openapi", "graphql"]),31 sourceType: z.enum(["mcp", "openapi", "graphql"]),
@@ -44,6 +35,12 @@ const compiledToolSourceArtifactSchema = z.object({ · 3 unmodified lines
44 tools: z.array(z.unknown()),35 tools: z.array(z.unknown()),
45});36});
46 37
38type CompiledToolSourceArtifactEnvelope = z.infer<typeof compiledToolSourceArtifactSchema>;
39
40export type CompiledToolSourceArtifact = Omit<CompiledToolSourceArtifactEnvelope, "tools"> & {
41 tools: SerializedTool[];
42};
43
47export function parseCompiledToolSourceArtifact(value: unknown): Result<CompiledToolSourceArtifact, Error> {44export function parseCompiledToolSourceArtifact(value: unknown): Result<CompiledToolSourceArtifact, Error> {
48 const parsedArtifact = compiledToolSourceArtifactSchema.safeParse(value);45 const parsedArtifact = compiledToolSourceArtifactSchema.safeParse(value);
49 if (!parsedArtifact.success) {46 if (!parsedArtifact.success) {
@@ -60,11 +57,7 @@ export function parseCompiledToolSourceArtifact(value: unknown): Result<Compiled · 10 unmodified lines
60 }57 }
61 58
62 return Result.ok({59 return Result.ok({
63 version: parsedArtifact.data.version,60 ...parsedArtifact.data,
64 sourceType: parsedArtifact.data.sourceType,
65 sourceName: parsedArtifact.data.sourceName,
66 openApiSourceKey: parsedArtifact.data.openApiSourceKey,
67 openApiRefHintTable: parsedArtifact.data.openApiRefHintTable,
68 tools,61 tools,
69 });62 });
70}63}
executor/packages/core/src/tool/source-auth.test.ts+30−21
@@ -2,22 +2,21 @@ import { expect, test } from "bun:test"; · 1 unmodified line
2import {2import {
3 buildCredentialAuthHeaders,3 buildCredentialAuthHeaders,
4 buildCredentialSpec,4 buildCredentialSpec,
5 buildStaticAuthHeaders,5 normalizeCredentialAdditionalHeaders,
6 readCredentialOverrideHeaders,6 readCredentialAdditionalHeaders,
7} from "./source-auth";7} from "./source-auth";
8 8
9test("buildStaticAuthHeaders returns static bearer header", () => {9test("buildCredentialSpec defaults mode to workspace and preserves account mode", () => {
10 const headers = buildStaticAuthHeaders({
11 type: "bearer",
12 mode: "static",
13 token: "secret-token",
14 });
15
16 expect(headers).toEqual({ authorization: "Bearer secret-token" });
17});
18
19test("buildCredentialSpec omits static auth and preserves account mode", () => {
20 expect(buildCredentialSpec("source:test", { type: "none" })).toBeUndefined();10 expect(buildCredentialSpec("source:test", { type: "none" })).toBeUndefined();
11 expect(
12 buildCredentialSpec("source:test", {
13 type: "bearer",
14 }),
15 ).toEqual({
16 sourceKey: "source:test",
17 mode: "workspace",
18 authType: "bearer",
19 });
21 expect(20 expect(
22 buildCredentialSpec("source:test", {21 buildCredentialSpec("source:test", {
23 type: "apiKey",22 type: "apiKey",
@@ -61,17 +60,27 @@ test("buildCredentialAuthHeaders supports basic auth aliases", () => { · 37 unmodified lines
61 });60 });
62});61});
63 62
64test("readCredentialOverrideHeaders trims keys and coerces values", () => {63test("readCredentialAdditionalHeaders trims keys and filters reserved names", () => {
65 const headers = readCredentialOverrideHeaders({64 const headers = readCredentialAdditionalHeaders([
66 headers: {65 { name: " x-trace-id ", value: "trace-1" },
67 " x-trace-id ": "trace-1",66 { name: "", value: "ignored" },
68 "": "ignored",67 { name: "authorization", value: "blocked" },
69 "x-retry": 2,68 { name: "x-retry", value: 2 },
70 },69 ]);
71 });
72 70
73 expect(headers).toEqual({71 expect(headers).toEqual({
74 "x-trace-id": "trace-1",72 "x-trace-id": "trace-1",
75 "x-retry": "2",73 "x-retry": "2",
76 });74 });
77});75});
76
77test("normalizeCredentialAdditionalHeaders deduplicates by header name", () => {
78 const headers = normalizeCredentialAdditionalHeaders([
79 { name: "x-tenant-id", value: "acme" },
80 { name: "X-TENANT-ID", value: "acme-2" },
81 ]);
82
83 expect(headers).toEqual([
84 { name: "X-TENANT-ID", value: "acme-2" },
85 ]);
86});
executor/packages/core/src/tool/source-auth.ts+39−33
@@ -1,16 +1,25 @@
1import type { OpenApiAuth } from "./source-types";1import type { OpenApiAuth } from "./source-types";
2import type { ToolCredentialSpec } from "../types";2import type { CredentialAdditionalHeader, ToolCredentialAuthType, ToolCredentialSpec } from "../types";
3import { z } from "zod";3import { z } from "zod";
4 4
5export type CredentialHeaderAuthSpec = {5export type CredentialHeaderAuthSpec = {
6 authType: "bearer" | "apiKey" | "basic";6 authType: ToolCredentialAuthType;
7 headerName?: string;7 headerName?: string;
8};8};
9 9
10const secretRecordSchema = z.record(z.unknown());10const secretRecordSchema = z.record(z.unknown());
11const credentialOverrideHeadersSchema = z.object({11const credentialAdditionalHeadersSchema = z.array(z.object({
12 headers: z.record(z.coerce.string()).optional(),12 name: z.string(),
13});13 value: z.coerce.string(),
14}));
15const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
16const RESERVED_HEADER_NAMES = new Set([
17 "authorization",
18 "cookie",
19 "host",
20 "content-length",
21 "transfer-encoding",
22]);
14 23
15function toRecord(value: unknown): Record<string, unknown> {24function toRecord(value: unknown): Record<string, unknown> {
16 const parsed = secretRecordSchema.safeParse(value);25 const parsed = secretRecordSchema.safeParse(value);
@@ -70,46 +79,43 @@ export function buildCredentialAuthHeaders( · 53 unmodified lines
70 return { authorization: `Basic ${encoded}` };79 return { authorization: `Basic ${encoded}` };
71}80}
72 81
73export function readCredentialOverrideHeaders(value: unknown): Record<string, string> {82export function normalizeCredentialAdditionalHeaders(value: unknown): CredentialAdditionalHeader[] {
74 const parsed = credentialOverrideHeadersSchema.safeParse(value);83 const parsed = credentialAdditionalHeadersSchema.safeParse(value);
75 const rawHeaders = parsed.success ? (parsed.data.headers ?? {}) : {};84 const rawHeaders = parsed.success ? parsed.data : [];
76 85
77 const normalized: Record<string, string> = {};86 const dedupedByName = new Map<string, CredentialAdditionalHeader>();
78 for (const [rawKey, rawValue] of Object.entries(rawHeaders)) {87 for (const entry of rawHeaders) {
79 const key = rawKey.trim();88 const name = entry.name.trim();
80 if (!key) continue;89 const value = entry.value.trim();
81 normalized[key] = rawValue;90 if (!name || !value) continue;
91
92 const normalizedName = name.toLowerCase();
93 if (!HEADER_NAME_PATTERN.test(name)) continue;
94 if (RESERVED_HEADER_NAMES.has(normalizedName)) continue;
95
96 dedupedByName.set(normalizedName, {
97 name,
98 value,
99 });
82 }100 }
83 101
84 return normalized;102 return [...dedupedByName.values()];
85}103}
86 104
87export function buildStaticAuthHeaders(auth?: OpenApiAuth): Record<string, string> {105export function readCredentialAdditionalHeaders(value: unknown): Record<string, string> {
88 if (!auth || auth.type === "none") return {};106 const headers = normalizeCredentialAdditionalHeaders(value);
89 const mode = auth.mode ?? "static";
90 if (mode !== "static") return {};
91
92 if (auth.type === "basic") {
93 const username = auth.username ?? "";
94 const password = auth.password ?? "";
95 if (!username && !password) return {};
96 const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
97 return { authorization: `Basic ${encoded}` };
98 }
99 107
100 if (auth.type === "bearer") {108 const normalized: Record<string, string> = {};
101 if (!auth.token) return {};109 for (const header of headers) {
102 return { authorization: `Bearer ${auth.token}` };110 normalized[header.name] = header.value;
103 }111 }
104 112
105 if (!auth.value) return {};113 return normalized;
106 return { [auth.header]: auth.value };
107}114}
108 115
109export function buildCredentialSpec(sourceKey: string, auth?: OpenApiAuth): ToolCredentialSpec | undefined {116export function buildCredentialSpec(sourceKey: string, auth?: OpenApiAuth): ToolCredentialSpec | undefined {
110 if (!auth || auth.type === "none") return undefined;117 if (!auth || auth.type === "none") return undefined;
111 const mode = auth.mode ?? "static";118 const mode = auth.mode ?? "workspace";
112 if (mode === "static") return undefined;
113 119
114 if (auth.type === "bearer") {120 if (auth.type === "bearer") {
115 return {121 return {
executor/packages/core/src/tool/source-serialization.ts+0−1
@@ -90,7 +90,6 @@ const toolCredentialSpecSchema = z.object({ · 89 unmodified lines
90 mode: z.enum(CREDENTIAL_SCOPE_TYPES),90 mode: z.enum(CREDENTIAL_SCOPE_TYPES),
91 authType: z.enum(TOOL_CREDENTIAL_AUTH_TYPES),91 authType: z.enum(TOOL_CREDENTIAL_AUTH_TYPES),
92 headerName: z.string().optional(),92 headerName: z.string().optional(),
93 staticSecretJson: z.record(z.unknown()).optional(),
94});93});
95 94
96const invalidSerializedToolFallbackSchema = z.object({95const invalidSerializedToolFallbackSchema = z.object({
executor/packages/core/src/tool/source-types.ts+3−3
@@ -16,9 +16,9 @@ export interface McpToolSourceConfig { · 15 unmodified lines
16 16
17export type OpenApiAuth =17export type OpenApiAuth =
18 | { type: "none" }18 | { type: "none" }
19 | { type: "basic"; mode?: "static" | "account" | "workspace" | "organization"; username?: string; password?: string }19 | { type: "basic"; mode?: "account" | "workspace" | "organization" }
20 | { type: "bearer"; mode?: "static" | "account" | "workspace" | "organization"; token?: string }20 | { type: "bearer"; mode?: "account" | "workspace" | "organization" }
21 | { type: "apiKey"; mode?: "static" | "account" | "workspace" | "organization"; header: string; value?: string };21 | { type: "apiKey"; mode?: "account" | "workspace" | "organization"; header: string };
22 22
23export interface OpenApiToolSourceConfig {23export interface OpenApiToolSourceConfig {
24 type: "openapi";24 type: "openapi";
executor/packages/core/src/types.ts+6−2
@@ -74,6 +74,11 @@ export type CredentialScope = CredentialScopeType; · 73 unmodified lines
74export const CREDENTIAL_PROVIDERS = ["local-convex", "workos-vault"] as const;74export const CREDENTIAL_PROVIDERS = ["local-convex", "workos-vault"] as const;
75export type CredentialProvider = (typeof CREDENTIAL_PROVIDERS)[number];75export type CredentialProvider = (typeof CREDENTIAL_PROVIDERS)[number];
76 76
77export interface CredentialAdditionalHeader {
78 name: string;
79 value: string;
80}
81
77export const TOOL_APPROVAL_MODES = ["auto", "required"] as const;82export const TOOL_APPROVAL_MODES = ["auto", "required"] as const;
78export type ToolApprovalMode = (typeof TOOL_APPROVAL_MODES)[number];83export type ToolApprovalMode = (typeof TOOL_APPROVAL_MODES)[number];
79 84
@@ -255,7 +260,7 @@ export interface CredentialRecord { · 175 unmodified lines
255 organizationId: Id<"organizations">;260 organizationId: Id<"organizations">;
256 workspaceId?: Id<"workspaces">;261 workspaceId?: Id<"workspaces">;
257 sourceKey: string;262 sourceKey: string;
258 overridesJson?: Record<string, unknown>;263 additionalHeaders: CredentialAdditionalHeader[];
259 boundAuthFingerprint?: string;264 boundAuthFingerprint?: string;
260 provider: CredentialProvider;265 provider: CredentialProvider;
261 secretJson: Record<string, unknown>;266 secretJson: Record<string, unknown>;
@@ -402,7 +407,6 @@ export interface ToolCredentialSpec { · 140 unmodified lines
402 mode: CredentialScope;407 mode: CredentialScope;
403 authType: ToolCredentialAuthType;408 authType: ToolCredentialAuthType;
404 headerName?: string;409 headerName?: string;
405 staticSecretJson?: Record<string, unknown>;
406}410}
407 411
408export interface ResolvedToolCredential {412export interface ResolvedToolCredential {