History

Enhance schema validation and error handling across various modules using Zod and Result utilities. This includes adding schemas for OpenAPI, MCP, and Postman run specifications, improving payload parsing, and ensuring robust error reporting in runtime configurations and tool discovery processes.

Rhys Sullivan committed 8 months ago

Commit 4cf575f1 parent 26ceb2c34 files · +1189 −373Browse files

Unified · Split

34 files
  1. executor/packages/core/src/credential-providers.ts +15 −12
  2. executor/packages/core/src/graphql/field-tools.ts +8 −10
  3. executor/packages/core/src/managed/runtime-bootstrap.ts +15 −6
  4. executor/packages/core/src/managed/runtime-info.ts +27 −1
  5. executor/packages/core/src/mcp-runtime.ts +15 −6
  6. executor/packages/core/src/mcp/server-approval.ts +28 −12
  7. executor/packages/core/src/openapi-auth.ts +25 −7
  8. executor/packages/core/src/openapi-prepare.ts +71 −22
  9. executor/packages/core/src/openapi/schema-hints.ts +8 −8
  10. executor/packages/core/src/openapi/tool-builder.ts +11 −7
  11. executor/packages/core/src/postman-runtime.ts +16 −5
  12. executor/packages/core/src/postman-utils.ts +3 −2
  13. executor/packages/core/src/postman/collection-utils.ts +5 −8
  14. executor/packages/core/src/runtimes/runtime-core.ts +1 −1
  15. executor/packages/core/src/tool-discovery.ts +35 −11
  16. executor/packages/core/src/tool-discovery/indexing.ts +2 −6
  17. executor/packages/core/src/tool-source-loaders/graphql-loader.ts +98 −14
  18. executor/packages/core/src/tool-source-loaders/mcp-loader.ts +15 −3
  19. executor/packages/core/src/tool-source-loaders/openapi-loader.ts +26 −8
  20. executor/packages/core/src/tool-sources.ts +61 −1
  21. executor/packages/core/src/tool-typing/convex-sanitize.ts +2 −1
  22. executor/packages/core/src/tool-typing/schema-utils.ts +17 −16
  23. executor/packages/core/src/tool-typing/typebundle.ts +2 −1
  24. executor/packages/core/src/tool/source-serialization.ts +150 −7
  25. executor/packages/core/src/workspace/tool-cache.test.ts +42 −11
  26. executor/packages/database/src/auth/event_handlers.ts +22 −11
  27. executor/packages/database/src/database/tool_source_config.test.ts +30 −0
  28. executor/packages/database/src/database/tool_source_config.ts +106 −43
  29. executor/packages/database/src/runtime/tool_call_credentials.ts +34 −8
  30. executor/packages/database/src/runtime/tool_call_resolution.ts +29 −11
  31. executor/packages/database/src/runtime/tool_invocation.ts +70 −19
  32. executor/packages/database/src/runtime/tool_registry_state.ts +22 −15
  33. executor/packages/database/src/runtime/tool_source_loading.ts +96 −42
  34. executor/packages/database/src/runtime/workspace_tools.ts +82 −38
executor/packages/core/src/credential-providers.ts+15−12
@@ -1,6 +1,8 @@
1"use node";1"use node";
2 2
3import { WorkOS } from "@workos-inc/node";3import { WorkOS } from "@workos-inc/node";
4import { Result } from "better-result";
5import { z } from "zod";
4import type { CredentialProvider, CredentialRecord } from "./types";6import type { CredentialProvider, CredentialRecord } from "./types";
5import { asRecord } from "./utils";7import { asRecord } from "./utils";
6 8
@@ -18,6 +20,12 @@ type ProviderResolver = ( · 11 unmodified lines
18 readVaultObject: VaultObjectReader,20 readVaultObject: VaultObjectReader,
19) => Promise<CredentialPayload | null>;21) => Promise<CredentialPayload | null>;
20 22
23const workosVaultSecretConfigSchema = z.object({
24 objectId: z.string().optional(),
25 id: z.string().optional(),
26 apiKey: z.string().optional(),
27});
28
21function resolveWorkosApiKey(explicitApiKey?: string): string {29function resolveWorkosApiKey(explicitApiKey?: string): string {
22 const candidate =30 const candidate =
23 explicitApiKey?.trim() ||31 explicitApiKey?.trim() ||
@@ -84,14 +92,13 @@ function parseSecretValue(raw: string): CredentialPayload { · 60 unmodified lines
84 return {};92 return {};
85 }93 }
86 94
87 try {95 const parsedJson = Result.try(() => JSON.parse(value));
88 const parsed = JSON.parse(value);96 if (parsedJson.isOk()) {
97 const parsed = parsedJson.value;
89 const asObject = asRecord(parsed);98 const asObject = asRecord(parsed);
90 if (Object.keys(asObject).length > 0) {99 if (Object.keys(asObject).length > 0) {
91 return asObject;100 return asObject;
92 }101 }
93 } catch {
94 // not JSON; fall through
95 }102 }
96 103
97 return { token: value };104 return { token: value };
@@ -107,20 +114,16 @@ async function resolveWorkosVault( · 9 unmodified lines
107 record: Pick<CredentialRecord, "secretJson">,114 record: Pick<CredentialRecord, "secretJson">,
108 readVaultObject: VaultObjectReader,115 readVaultObject: VaultObjectReader,
109): Promise<CredentialPayload | null> {116): Promise<CredentialPayload | null> {
110 const config = asRecord(record.secretJson);117 const parsedConfig = workosVaultSecretConfigSchema.safeParse(asRecord(record.secretJson));
111 const objectId =118 const config = parsedConfig.success ? parsedConfig.data : {};
112 typeof config.objectId === "string" && config.objectId.trim()119 const objectId = config.objectId?.trim() || config.id?.trim() || "";
113 ? config.objectId.trim()
114 : typeof config.id === "string" && config.id.trim()
115 ? config.id.trim()
116 : "";
117 120
118 if (!objectId) {121 if (!objectId) {
119 throw new Error(122 throw new Error(
120 "Encrypted credential is missing its secure reference. Re-save this credential in the dashboard.",123 "Encrypted credential is missing its secure reference. Re-save this credential in the dashboard.",
121 );124 );
122 }125 }
123 const apiKey = typeof config.apiKey === "string" && config.apiKey.trim() ? config.apiKey.trim() : undefined;126 const apiKey = config.apiKey?.trim() || undefined;
124 127
125 const raw = await readVaultObject({ objectId, apiKey });128 const raw = await readVaultObject({ objectId, apiKey });
126 return parseSecretValue(raw);129 return parseSecretValue(raw);
executor/packages/core/src/graphql/field-tools.ts+8−10
@@ -1,4 +1,5 @@
1import type { GraphqlExecutionEnvelope } from "../tool/source-execution";1import type { GraphqlExecutionEnvelope } from "../tool/source-execution";
2import { asRecord } from "../utils";
2 3
3export interface GqlTypeRef {4export interface GqlTypeRef {
4 kind: string;5 kind: string;
@@ -48,15 +49,12 @@ export function selectGraphqlFieldEnvelope( · 43 unmodified lines
48 envelope: GraphqlExecutionEnvelope,49 envelope: GraphqlExecutionEnvelope,
49 operationName: string,50 operationName: string,
50): GraphqlExecutionEnvelope {51): GraphqlExecutionEnvelope {
51 const data = envelope.data;52 const record = asRecord(envelope.data);
52 if (data && typeof data === "object" && !Array.isArray(data)) {53 if (Object.prototype.hasOwnProperty.call(record, operationName)) {
53 const record = data as Record<string, unknown>;54 return {
54 if (Object.prototype.hasOwnProperty.call(record, operationName)) {55 data: record[operationName],
55 return {56 errors: envelope.errors,
56 data: record[operationName],57 };
57 errors: envelope.errors,
58 };
59 }
60 }58 }
61 59
62 return envelope;60 return envelope;
@@ -84,7 +82,7 @@ export function normalizeGraphqlFieldVariables( · 21 unmodified lines
84 && typeof value === "object"82 && typeof value === "object"
85 && !Array.isArray(value)83 && !Array.isArray(value)
86 ) {84 ) {
87 const nested = value as Record<string, unknown>;85 const nested = asRecord(value);
88 if (Object.keys(nested).length === 1 && Object.prototype.hasOwnProperty.call(nested, argName)) {86 if (Object.keys(nested).length === 1 && Object.prototype.hasOwnProperty.call(nested, argName)) {
89 return { [argName]: nested[argName] };87 return { [argName]: nested[argName] };
90 }88 }
executor/packages/core/src/managed/runtime-bootstrap.ts+15−6
@@ -1,12 +1,21 @@
1import fs from "node:fs/promises";1import fs from "node:fs/promises";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
4import { z } from "zod";
4 5
5import type { ManagedRuntimeInfo } from "../managed-runtime";6import type { ManagedRuntimeInfo } from "../managed-runtime";
6import { managedRuntimeVersions, pathExists } from "./runtime-info";7import { managedRuntimeVersions, pathExists } from "./runtime-info";
7import { ensureConvexCliRuntime } from "./runtime-installation";8import { ensureConvexCliRuntime } from "./runtime-installation";
8import { runProcess } from "./runtime-process";9import { runProcess } from "./runtime-process";
9 10
11const adminKeyResponseSchema = z.object({
12 adminKey: z.string().min(1),
13});
14
15const convexJsonSchema = z.object({
16 functions: z.string().optional(),
17});
18
10const BOOTSTRAP_REQUIRED_DEPENDENCIES = [19const BOOTSTRAP_REQUIRED_DEPENDENCIES = [
11 "convex",20 "convex",
12 "@convex-dev/migrations",21 "@convex-dev/migrations",
@@ -41,12 +50,12 @@ async function generateSelfHostedAdminKey(info: ManagedRuntimeInfo): Promise<str · 28 unmodified lines
41 throw new Error(`Failed generating self-hosted admin key: ${text || response.statusText}`);50 throw new Error(`Failed generating self-hosted admin key: ${text || response.statusText}`);
42 }51 }
43 52
44 const parsed = (await response.json()) as { adminKey?: string };53 const parsed = adminKeyResponseSchema.safeParse(await response.json());
45 if (!parsed.adminKey) {54 if (!parsed.success) {
46 throw new Error("Convex admin key generation did not return an admin key.");55 throw new Error("Convex admin key generation did not return an admin key.");
47 }56 }
48 57
49 return parsed.adminKey;58 return parsed.data.adminKey;
50}59}
51 60
52async function hasAnyPath(paths: string[]): Promise<boolean> {61async function hasAnyPath(paths: string[]): Promise<boolean> {
@@ -72,11 +81,11 @@ function getConfigCandidates(baseDir: string): string[] { · 19 unmodified lines
72async function getConfiguredFunctionsPath(candidate: string): Promise<string | null> {81async function getConfiguredFunctionsPath(candidate: string): Promise<string | null> {
73 try {82 try {
74 const raw = await fs.readFile(path.join(candidate, "convex.json"), "utf8");83 const raw = await fs.readFile(path.join(candidate, "convex.json"), "utf8");
75 const parsed = JSON.parse(raw) as { functions?: unknown };84 const parsed = convexJsonSchema.safeParse(JSON.parse(raw));
76 if (typeof parsed.functions !== "string") {85 if (!parsed.success || typeof parsed.data.functions !== "string") {
77 return null;86 return null;
78 }87 }
79 const value = parsed.functions.trim();88 const value = parsed.data.functions.trim();
80 return value.length > 0 ? value : null;89 return value.length > 0 ? value : null;
81 } catch {90 } catch {
82 return null;91 return null;
executor/packages/core/src/managed/runtime-info.ts+27−1
@@ -1,9 +1,19 @@
1import fs from "node:fs/promises";1import fs from "node:fs/promises";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
4import { Result } from "better-result";
5import { z } from "zod";
4 6
5import type { ManagedRuntimeConfig, ManagedRuntimeInfo } from "../managed-runtime";7import type { ManagedRuntimeConfig, ManagedRuntimeInfo } from "../managed-runtime";
6 8
9const managedRuntimeConfigSchema = z.object({
10 instanceName: z.string().min(1),
11 instanceSecret: z.string().min(1),
12 hostInterface: z.string().min(1),
13 backendPort: z.number().int(),
14 siteProxyPort: z.number().int(),
15});
16
7const CONVEX_BACKEND_REPO = "get-convex/convex-backend";17const CONVEX_BACKEND_REPO = "get-convex/convex-backend";
8const EXECUTOR_RELEASE_REPO = Bun.env.EXECUTOR_REPO ?? "RhysSullivan/executor";18const EXECUTOR_RELEASE_REPO = Bun.env.EXECUTOR_REPO ?? "RhysSullivan/executor";
9const NODE_VERSION = "22.22.0";19const NODE_VERSION = "22.22.0";
@@ -81,6 +91,11 @@ function defaultConfig(): ManagedRuntimeConfig { · 71 unmodified lines
81 };91 };
82}92}
83 93
94function parseManagedRuntimeConfig(value: unknown): ManagedRuntimeConfig | null {
95 const parsed = managedRuntimeConfigSchema.safeParse(value);
96 return parsed.success ? parsed.data : null;
97}
98
84export async function pathExists(filePath: string): Promise<boolean> {99export async function pathExists(filePath: string): Promise<boolean> {
85 try {100 try {
86 await fs.access(filePath);101 await fs.access(filePath);
@@ -136,7 +151,18 @@ export function runtimeInfo(): ManagedRuntimeInfo { · 49 unmodified lines
136export async function ensureConfig(info: ManagedRuntimeInfo): Promise<ManagedRuntimeConfig> {151export async function ensureConfig(info: ManagedRuntimeInfo): Promise<ManagedRuntimeConfig> {
137 if (await pathExists(info.configPath)) {152 if (await pathExists(info.configPath)) {
138 const raw = await fs.readFile(info.configPath, "utf8");153 const raw = await fs.readFile(info.configPath, "utf8");
139 const parsed = JSON.parse(raw) as ManagedRuntimeConfig;154 const parsedResult = Result.try(() => JSON.parse(raw));
155 const parsed = parsedResult.isOk()
156 ? parseManagedRuntimeConfig(parsedResult.value)
157 : null;
158
159 if (!parsed) {
160 const config = defaultConfig();
161 await fs.writeFile(info.configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
162 console.warn(`[executor] replaced invalid runtime config at ${info.configPath}`);
163 return config;
164 }
165
140 if (parsed.instanceName === "executor-local") {166 if (parsed.instanceName === "executor-local") {
141 parsed.instanceName = "anonymous-executor";167 parsed.instanceName = "anonymous-executor";
142 await fs.writeFile(info.configPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");168 await fs.writeFile(info.configPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
executor/packages/core/src/mcp-runtime.ts+15−6
@@ -1,6 +1,11 @@
1import { Client } from "@modelcontextprotocol/sdk/client/index.js";1import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";2import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";3import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4import { z } from "zod";
5
6const mcpToolResultSchema = z.object({
7 content: z.array(z.object({ text: z.string().optional() }).passthrough()).optional(),
8}).passthrough();
4 9
5function withHeaders(10function withHeaders(
6 input: RequestInfo | URL,11 input: RequestInfo | URL,
@@ -50,14 +55,14 @@ export async function connectMcp( · 43 unmodified lines
50 if (preferredTransport === "streamable-http") {55 if (preferredTransport === "streamable-http") {
51 await client.connect(new StreamableHTTPClientTransport(endpoint, {56 await client.connect(new StreamableHTTPClientTransport(endpoint, {
52 requestInit: headers ? { headers } : undefined,57 requestInit: headers ? { headers } : undefined,
53 }) as Parameters<Client["connect"]>[0]);58 }));
54 return { client, close: () => client.close() };59 return { client, close: () => client.close() };
55 }60 }
56 61
57 try {62 try {
58 await client.connect(new StreamableHTTPClientTransport(endpoint, {63 await client.connect(new StreamableHTTPClientTransport(endpoint, {
59 requestInit: headers ? { headers } : undefined,64 requestInit: headers ? { headers } : undefined,
60 }) as Parameters<Client["connect"]>[0]);65 }));
61 return { client, close: () => client.close() };66 return { client, close: () => client.close() };
62 } catch {67 } catch {
63 await client.connect(new SSEClientTransport(endpoint, {68 await client.connect(new SSEClientTransport(endpoint, {
@@ -73,12 +78,16 @@ export async function connectMcp( · 9 unmodified lines
73}78}
74 79
75export function extractMcpResult(result: unknown): unknown {80export function extractMcpResult(result: unknown): unknown {
76 if (!result || typeof result !== "object") return result;81 const parsed = mcpToolResultSchema.safeParse(result);
77 const content = (result as { content?: unknown }).content;82 if (!parsed.success || !Array.isArray(parsed.data.content)) {
78 if (!Array.isArray(content)) return result;83 return result;
84 }
85
86 const content = parsed.data.content;
79 const texts = content87 const texts = content
80 .map((item) => (item && typeof item === "object" ? (item as { text?: unknown }).text : undefined))88 .map((item) => item.text)
81 .filter((item): item is string => typeof item === "string");89 .filter((item): item is string => typeof item === "string");
90
82 if (texts.length === 0) return content;91 if (texts.length === 0) return content;
83 if (texts.length === 1) return texts[0];92 if (texts.length === 1) return texts[0];
84 return texts;93 return texts;
executor/packages/core/src/mcp/server-approval.ts+28−12
@@ -1,4 +1,5 @@
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { z } from "zod";
2import type { PendingApprovalRecord, TaskRecord } from "../types";3import type { PendingApprovalRecord, TaskRecord } from "../types";
3import type { Id } from "../../../database/convex/_generated/dataModel";4import type { Id } from "../../../database/convex/_generated/dataModel";
4import type {5import type {
@@ -8,6 +9,15 @@ import type { · 3 unmodified lines
8 McpExecutorService,9 McpExecutorService,
9} from "./server-contracts";10} from "./server-contracts";
10import { getTaskTerminalState } from "./server-utils";11import { getTaskTerminalState } from "./server-utils";
12import { asRecord } from "../utils";
13
14const elicitationResponseSchema = z.object({
15 action: z.enum(["accept", "decline", "cancel"]),
16 content: z.object({
17 decision: z.enum(["approved", "denied"]).optional(),
18 reason: z.string().optional(),
19 }).optional(),
20});
11 21
12function formatApprovalInput(input: unknown, maxLength = 2000): string {22function formatApprovalInput(input: unknown, maxLength = 2000): string {
13 let serialized: string;23 let serialized: string;
@@ -42,7 +52,7 @@ function buildApprovalPromptMessage(approval: PendingApprovalRecord): string { · 28 unmodified lines
42 52
43export function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt {53export function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt {
44 return async (approval) => {54 return async (approval) => {
45 const response = await mcp.server.elicitInput({55 const rawResponse = await mcp.server.elicitInput({
46 mode: "form",56 mode: "form",
47 message: buildApprovalPromptMessage(approval),57 message: buildApprovalPromptMessage(approval),
48 requestedSchema: {58 requestedSchema: {
@@ -67,23 +77,31 @@ export function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt { · 18 unmodified lines
67 },77 },
68 required: ["decision"],78 required: ["decision"],
69 },79 },
70 }, { timeout: 15_000 }) as {80 }, { timeout: 15_000 });
71 action: "accept" | "decline" | "cancel";81
72 content?: Record<string, unknown>;82 const parsedResponse = elicitationResponseSchema.safeParse(rawResponse);
73 };83 if (!parsedResponse.success) {
84 return {
85 decision: "denied",
86 reason: "User canceled approval prompt",
87 };
88 }
89
90 const action = parsedResponse.data.action;
91 const content = asRecord(parsedResponse.data.content);
74 92
75 if (response.action !== "accept") {93 if (action !== "accept") {
76 return {94 return {
77 decision: "denied",95 decision: "denied",
78 reason: response.action === "decline"96 reason: action === "decline"
79 ? "User explicitly declined approval"97 ? "User explicitly declined approval"
80 : "User canceled approval prompt",98 : "User canceled approval prompt",
81 };99 };
82 }100 }
83 101
84 const selectedDecision = response.content?.decision;102 const selectedDecision = content.decision;
85 const decision = selectedDecision === "approved" ? "approved" : "denied";103 const decision = selectedDecision === "approved" ? "approved" : "denied";
86 const selectedReason = response.content?.reason;104 const selectedReason = content.reason;
87 const reason = typeof selectedReason === "string" && selectedReason.trim().length > 0105 const reason = typeof selectedReason === "string" && selectedReason.trim().length > 0
88 ? selectedReason106 ? selectedReason
89 : undefined;107 : undefined;
@@ -191,9 +209,7 @@ export function waitForTerminalTask( · 101 unmodified lines
191 };209 };
192 210
193 unsubscribe = service.subscribe(taskId, workspaceId, (event) => {211 unsubscribe = service.subscribe(taskId, workspaceId, (event) => {
194 const payload = typeof event.payload === "object" && event.payload212 const payload = asRecord(event.payload);
195 ? event.payload as Record<string, unknown>
196 : {};
197 const type = typeof payload.status === "string" ? payload.status : undefined;213 const type = typeof payload.status === "string" ? payload.status : undefined;
198 const pendingApprovalCount = typeof payload.pendingApprovalCount === "number"214 const pendingApprovalCount = typeof payload.pendingApprovalCount === "number"
199 ? payload.pendingApprovalCount215 ? payload.pendingApprovalCount
executor/packages/core/src/openapi-auth.ts+25−7
@@ -1,6 +1,16 @@
1import type { OpenApiAuth } from "./tool/source-types";1import type { OpenApiAuth } from "./tool/source-types";
2import { z } from "zod";
2import { asRecord } from "./utils";3import { asRecord } from "./utils";
3 4
5const securityRequirementSchema = z.record(z.array(z.unknown()).optional());
6
7const securitySchemeSchema = z.object({
8 type: z.string().optional(),
9 scheme: z.string().optional(),
10 in: z.string().optional(),
11 name: z.string().optional(),
12});
13
4export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | undefined {14export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | undefined {
5 const components = asRecord(spec.components);15 const components = asRecord(spec.components);
6 const securitySchemes = asRecord(components.securitySchemes);16 const securitySchemes = asRecord(components.securitySchemes);
@@ -9,21 +19,29 @@ export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | u · 2 unmodified lines
9 }19 }
10 20
11 const security = Array.isArray(spec.security)21 const security = Array.isArray(spec.security)
12 ? spec.security.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object"))22 ? spec.security
23 .map((entry) => securityRequirementSchema.safeParse(entry))
24 .filter((entry): entry is { success: true; data: Record<string, unknown[]> } => entry.success)
25 .map((entry) => entry.data)
13 : [];26 : [];
14 27
15 const referencedSchemeName = security28 const referencedSchemeName = security
16 .flatMap((entry) => Object.keys(entry))29 .flatMap((entry) => Object.keys(entry))
17 .find((name) => typeof securitySchemes[name] === "object");30 .find((name) => Object.prototype.hasOwnProperty.call(securitySchemes, name));
18 31
19 const schemeName = referencedSchemeName ?? Object.keys(securitySchemes)[0];32 const schemeName = referencedSchemeName ?? Object.keys(securitySchemes)[0];
20 if (!schemeName) return undefined;33 if (!schemeName) return undefined;
21 34
22 const scheme = asRecord(securitySchemes[schemeName]);35 const parsedScheme = securitySchemeSchema.safeParse(securitySchemes[schemeName]);
23 const type = String(scheme.type ?? "").toLowerCase();36 if (!parsedScheme.success) {
37 return undefined;
38 }
39
40 const scheme = parsedScheme.data;
41 const type = (scheme.type ?? "").toLowerCase();
24 42
25 if (type === "http") {43 if (type === "http") {
26 const httpScheme = String(scheme.scheme ?? "").toLowerCase();44 const httpScheme = (scheme.scheme ?? "").toLowerCase();
27 if (httpScheme === "bearer") {45 if (httpScheme === "bearer") {
28 return { type: "bearer", mode: "workspace" };46 return { type: "bearer", mode: "workspace" };
29 }47 }
@@ -34,8 +52,8 @@ export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | u · 4 unmodified lines
34 }52 }
35 53
36 if (type === "apikey") {54 if (type === "apikey") {
37 const location = String(scheme.in ?? "").toLowerCase();55 const location = (scheme.in ?? "").toLowerCase();
38 const header = typeof scheme.name === "string" ? scheme.name.trim() : "";56 const header = (scheme.name ?? "").trim();
39 if (location === "header" && header.length > 0) {57 if (location === "header" && header.length > 0) {
40 return { type: "apiKey", mode: "workspace", header };58 return { type: "apiKey", mode: "workspace", header };
41 }59 }
executor/packages/core/src/openapi-prepare.ts+71−22
@@ -1,29 +1,58 @@
1import SwaggerParser from "@apidevtools/swagger-parser";1import SwaggerParser from "@apidevtools/swagger-parser";
2import openapiTS, { astToString } from "openapi-typescript";2import openapiTS, { astToString } from "openapi-typescript";
3import { Result } from "better-result";
4import { z } from "zod";
3import { inferOpenApiAuth } from "./openapi-auth";5import { inferOpenApiAuth } from "./openapi-auth";
4import { compactOpenApiPaths } from "./openapi-compaction";6import { compactOpenApiPaths } from "./openapi-compaction";
5import { extractOperationIdsFromDts } from "./openapi/schema-hints";7import { extractOperationIdsFromDts } from "./openapi/schema-hints";
6import type { PreparedOpenApiSpec } from "./tool/source-types";8import type { PreparedOpenApiSpec } from "./tool/source-types";
7import { asRecord } from "./utils";9import { asRecord } from "./utils";
8 10
11const unknownRecordSchema = z.record(z.unknown());
12
9interface SwaggerParserAdapter {13interface SwaggerParserAdapter {
10 bundle(spec: unknown): Promise<unknown>;14 bundle(spec: unknown): Promise<unknown>;
11 parse(spec: unknown): Promise<unknown>;15 parse(spec: unknown): Promise<unknown>;
12}16}
13 17
14function createSwaggerParserAdapter(parserModule: unknown): SwaggerParserAdapter {18function createSwaggerParserAdapter(parserModule: unknown): SwaggerParserAdapter {
15 const parser = parserModule as Partial<SwaggerParserAdapter>;19 if ((typeof parserModule !== "object" || parserModule === null) && typeof parserModule !== "function") {
16 if (typeof parser.parse !== "function" || typeof parser.bundle !== "function") {20 throw new Error("SwaggerParser module is missing parse/bundle methods");
21 }
22
23 const parse = Reflect.get(parserModule, "parse");
24 const bundle = Reflect.get(parserModule, "bundle");
25 if (typeof parse !== "function" || typeof bundle !== "function") {
17 throw new Error("SwaggerParser module is missing parse/bundle methods");26 throw new Error("SwaggerParser module is missing parse/bundle methods");
18 }27 }
19 const parse = parser.parse!;28
20 const bundle = parser.bundle!;
21 return {29 return {
22 parse: (spec) => parse(spec),30 parse: (spec) => parse(spec),
23 bundle: (spec) => bundle(spec),31 bundle: (spec) => bundle(spec),
24 };32 };
25}33}
26 34
35function toRecordResult(value: unknown, label: string): Result<Record<string, unknown>, Error> {
36 const parsed = unknownRecordSchema.safeParse(value);
37 if (!parsed.success) {
38 return Result.err(new Error(`${label} must be an object: ${parsed.error.message}`));
39 }
40
41 return Result.ok(parsed.data);
42}
43
44function describeResultError(error: unknown): string {
45 const cause = asRecord(error).cause;
46 if (cause instanceof Error) {
47 return cause.message;
48 }
49 if (typeof cause === "string" && cause.trim().length > 0) {
50 return cause;
51 }
52
53 return error instanceof Error ? error.message : String(error);
54}
55
27function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null {56function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null {
28 let strippedCount = 0;57 let strippedCount = 0;
29 58
@@ -33,7 +62,7 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 3 unmodified lines
33 let target: unknown = spec;62 let target: unknown = spec;
34 for (const segment of segments) {63 for (const segment of segments) {
35 if (target && typeof target === "object") {64 if (target && typeof target === "object") {
36 target = (target as Record<string, unknown>)[segment];65 target = asRecord(target)[segment];
37 } else {66 } else {
38 return false;67 return false;
39 }68 }
@@ -44,11 +73,11 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 4 unmodified lines
44 function hasBrokenDiscriminators(obj: unknown): boolean {73 function hasBrokenDiscriminators(obj: unknown): boolean {
45 if (Array.isArray(obj)) return obj.some(hasBrokenDiscriminators);74 if (Array.isArray(obj)) return obj.some(hasBrokenDiscriminators);
46 if (obj && typeof obj === "object") {75 if (obj && typeof obj === "object") {
47 const record = obj as Record<string, unknown>;76 const record = asRecord(obj);
48 if (record.discriminator && typeof record.discriminator === "object") {77 if (record.discriminator && typeof record.discriminator === "object") {
49 const disc = record.discriminator as Record<string, unknown>;78 const disc = asRecord(record.discriminator);
50 if (disc.mapping && typeof disc.mapping === "object") {79 if (disc.mapping && typeof disc.mapping === "object") {
51 const mapping = disc.mapping as Record<string, string>;80 const mapping = asRecord(disc.mapping);
52 if (Object.values(mapping).some((ref) => typeof ref === "string" && !refExists(ref))) {81 if (Object.values(mapping).some((ref) => typeof ref === "string" && !refExists(ref))) {
53 return true;82 return true;
54 }83 }
@@ -64,13 +93,13 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 9 unmodified lines
64 function walk(obj: unknown): unknown {93 function walk(obj: unknown): unknown {
65 if (Array.isArray(obj)) return obj.map(walk);94 if (Array.isArray(obj)) return obj.map(walk);
66 if (obj && typeof obj === "object") {95 if (obj && typeof obj === "object") {
67 const record = obj as Record<string, unknown>;96 const record = asRecord(obj);
68 const clone: Record<string, unknown> = {};97 const clone: Record<string, unknown> = {};
69 for (const [key, value] of Object.entries(record)) {98 for (const [key, value] of Object.entries(record)) {
70 if (key === "discriminator" && typeof value === "object" && value !== null) {99 if (key === "discriminator" && typeof value === "object" && value !== null) {
71 const disc = value as Record<string, unknown>;100 const disc = asRecord(value);
72 if (disc.mapping && typeof disc.mapping === "object") {101 if (disc.mapping && typeof disc.mapping === "object") {
73 const mapping = disc.mapping as Record<string, string>;102 const mapping = asRecord(disc.mapping);
74 const hasBroken = Object.values(mapping).some(103 const hasBroken = Object.values(mapping).some(
75 (ref) => typeof ref === "string" && !refExists(ref),104 (ref) => typeof ref === "string" && !refExists(ref),
76 );105 );
@@ -87,7 +116,11 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 10 unmodified lines
87 return obj;116 return obj;
88 }117 }
89 118
90 const result = walk(spec) as Record<string, unknown>;119 const resultRecord = toRecordResult(walk(spec), "Patched OpenAPI spec");
120 if (resultRecord.isErr()) {
121 return null;
122 }
123 const result = resultRecord.value;
91 console.warn(`[executor] stripped ${strippedCount} broken discriminator(s) from OpenAPI spec`);124 console.warn(`[executor] stripped ${strippedCount} broken discriminator(s) from OpenAPI spec`);
92 return result;125 return result;
93}126}
@@ -135,12 +168,18 @@ export async function prepareOpenApiSpec( · 41 unmodified lines
135 168
136 let parsed: Record<string, unknown>;169 let parsed: Record<string, unknown>;
137 if (typeof spec === "string") {170 if (typeof spec === "string") {
138 try {171 const parsedResult = await Result.tryPromise(() => parser.parse(spec));
139 parsed = (await parser.parse(spec)) as Record<string, unknown>;172 if (parsedResult.isErr()) {
140 } catch (parseError) {173 const msg = describeResultError(parsedResult.error);
141 const msg = parseError instanceof Error ? parseError.message : String(parseError);
142 throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`);174 throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`);
143 }175 }
176
177 const parsedRecord = toRecordResult(parsedResult.value, `Parsed OpenAPI source '${sourceName}'`);
178 if (parsedRecord.isErr()) {
179 throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${parsedRecord.error.message}`);
180 }
181
182 parsed = parsedRecord.value;
144 } else {183 } else {
145 parsed = spec;184 parsed = spec;
146 }185 }
@@ -150,12 +189,19 @@ export async function prepareOpenApiSpec( · 3 unmodified lines
150 ? generateOpenApiDts(parsed)189 ? generateOpenApiDts(parsed)
151 : Promise.resolve<string | null>(null);190 : Promise.resolve<string | null>(null);
152 if (shouldBundle) {191 if (shouldBundle) {
153 try {192 const bundleResult = await Result.tryPromise(() => parser.bundle(parsed));
154 bundled = (await parser.bundle(parsed)) as Record<string, unknown>;193 if (bundleResult.isErr()) {
155 } catch (bundleError) {194 const bundleMessage = describeResultError(bundleResult.error);
156 const bundleMessage = bundleError instanceof Error ? bundleError.message : String(bundleError);
157 warnings.push(`OpenAPI bundle failed for '${sourceName}', using parse-only mode: ${bundleMessage}`);195 warnings.push(`OpenAPI bundle failed for '${sourceName}', using parse-only mode: ${bundleMessage}`);
158 bundled = parsed;196 bundled = parsed;
197 } else {
198 const bundledRecord = toRecordResult(bundleResult.value, `Bundled OpenAPI source '${sourceName}'`);
199 if (bundledRecord.isErr()) {
200 warnings.push(`OpenAPI bundle returned non-object payload for '${sourceName}', using parse-only mode`);
201 bundled = parsed;
202 } else {
203 bundled = bundledRecord.value;
204 }
159 }205 }
160 } else {206 } else {
161 bundled = parsed;207 bundled = parsed;
@@ -163,12 +209,15 @@ export async function prepareOpenApiSpec( · 1 unmodified line
163 const dts = await dtsPromise;209 const dts = await dtsPromise;
164 210
165 const operationTypeIds = dts ? extractOperationIdsFromDts(dts) : new Set<string>();211 const operationTypeIds = dts ? extractOperationIdsFromDts(dts) : new Set<string>();
166 const servers = Array.isArray(bundled.servers) ? (bundled.servers as Array<{ url?: unknown }>) : [];212 const servers = Array.isArray(bundled.servers) ? bundled.servers : [];
167 const inferredAuth = inferOpenApiAuth(bundled);213 const inferredAuth = inferOpenApiAuth(bundled);
168 214
169 return {215 return {
170 servers: servers216 servers: servers
171 .map((server) => (typeof server.url === "string" ? server.url : ""))217 .map((server) => {
218 const value = asRecord(server).url;
219 return typeof value === "string" ? value : "";
220 })
172 .filter((url) => url.length > 0),221 .filter((url) => url.length > 0),
173 paths: compactOpenApiPaths(222 paths: compactOpenApiPaths(
174 bundled.paths,223 bundled.paths,
executor/packages/core/src/openapi/schema-hints.ts+8−8
@@ -4,7 +4,7 @@ type JsonSchema = Record<string, unknown>; · 3 unmodified lines
4const COMPONENT_REF_INLINE_DEPTH = 2;4const COMPONENT_REF_INLINE_DEPTH = 2;
5 5
6function isSmallInlineableComponentSchema(schema: Record<string, unknown>): boolean {6function isSmallInlineableComponentSchema(schema: Record<string, unknown>): boolean {
7 const shape = schema as JsonSchema;7 const shape = schema;
8 const type = typeof shape.type === "string" ? shape.type : undefined;8 const type = typeof shape.type === "string" ? shape.type : undefined;
9 const props = asRecord(shape.properties);9 const props = asRecord(shape.properties);
10 const propCount = Object.keys(props).length;10 const propCount = Object.keys(props).length;
@@ -302,8 +302,8 @@ function extractStringLiteralFromSchema(schema: JsonSchema): string | null { · 291 unmodified lines
302 if (enumValues && enumValues.length === 1 && typeof enumValues[0] === "string") {302 if (enumValues && enumValues.length === 1 && typeof enumValues[0] === "string") {
303 return enumValues[0];303 return enumValues[0];
304 }304 }
305 if (typeof (schema as Record<string, unknown>).const === "string") {305 if (typeof schema.const === "string") {
306 return String((schema as Record<string, unknown>).const);306 return String(schema.const);
307 }307 }
308 return null;308 return null;
309}309}
@@ -342,7 +342,7 @@ function repairMissingRequiredProperties( · 32 unmodified lines
342 if (!schema || typeof schema !== "object") continue;342 if (!schema || typeof schema !== "object") continue;
343 const hint = jsonSchemaTypeHintFallback(schema, depth + 1, componentSchemas, seenRefs);343 const hint = jsonSchemaTypeHintFallback(schema, depth + 1, componentSchemas, seenRefs);
344 if (!hint || hint === "unknown") continue;344 if (!hint || hint === "unknown") continue;
345 candidates.push({ hint, schema: schema as JsonSchema });345 candidates.push({ hint, schema: asRecord(schema) });
346 }346 }
347 347
348 if (candidates.length === 0) continue;348 if (candidates.length === 0) continue;
@@ -358,7 +358,7 @@ function repairMissingRequiredProperties( · 9 unmodified lines
358 358
359 for (let i = 0; i < variants.length; i++) {359 for (let i = 0; i < variants.length; i++) {
360 const variant = variants[i]!;360 const variant = variants[i]!;
361 const additionalProperties = (variant as Record<string, unknown>).additionalProperties;361 const additionalProperties = variant.additionalProperties;
362 // Only patch the strict case where the omission would make the schema invalid.362 // Only patch the strict case where the omission would make the schema invalid.
363 if (additionalProperties !== false) {363 if (additionalProperties !== false) {
364 out.push(variant);364 out.push(variant);
@@ -389,7 +389,7 @@ function repairMissingRequiredProperties( · 24 unmodified lines
389 out.push({389 out.push({
390 ...variant,390 ...variant,
391 properties: nextProps,391 properties: nextProps,
392 } as JsonSchema);392 });
393 }393 }
394 394
395 return changed ? out : variants;395 return changed ? out : variants;
@@ -788,7 +788,7 @@ export function jsonSchemaTypeHintFallback( · 392 unmodified lines
788 if (!schema || typeof schema !== "object") return "unknown";788 if (!schema || typeof schema !== "object") return "unknown";
789 if (depth > 12) return "unknown";789 if (depth > 12) return "unknown";
790 790
791 const shape = schema as JsonSchema;791 const shape = asRecord(schema);
792 if (typeof shape.$ref === "string") {792 if (typeof shape.$ref === "string") {
793 const ref = shape.$ref;793 const ref = shape.$ref;
794 const prefix = "#/components/schemas/";794 const prefix = "#/components/schemas/";
@@ -973,7 +973,7 @@ function collectTopLevelSchemaKeys( · 178 unmodified lines
973): string[] {973): string[] {
974 if (!schema || typeof schema !== "object") return [];974 if (!schema || typeof schema !== "object") return [];
975 975
976 const record = schema as Record<string, unknown>;976 const record = asRecord(schema);
977 const ref = typeof record.$ref === "string" ? record.$ref : "";977 const ref = typeof record.$ref === "string" ? record.$ref : "";
978 if (ref.startsWith("#/components/schemas/")) {978 if (ref.startsWith("#/components/schemas/")) {
979 if (seenRefs.has(ref)) return [];979 if (seenRefs.has(ref)) return [];
executor/packages/core/src/openapi/tool-builder.ts+11−7
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
6 jsonSchemaTypeHintFallback,6 jsonSchemaTypeHintFallback,
7 responseTypeHintFromSchema,7 responseTypeHintFromSchema,
8} from "./schema-hints";8} from "./schema-hints";
9import { z } from "zod";
9import { buildOpenApiToolPath } from "./tool-path";10import { buildOpenApiToolPath } from "./tool-path";
10import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";11import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
11import { executeOpenApiRequest } from "../tool/source-execution";12import { executeOpenApiRequest } from "../tool/source-execution";
@@ -23,15 +24,20 @@ type OpenApiOperationParameter = { · 11 unmodified lines
23 schema: Record<string, unknown>;24 schema: Record<string, unknown>;
24};25};
25 26
27const recordArraySchema = z.array(z.record(z.unknown()));
28
29function asRecordArray(value: unknown): Record<string, unknown>[] {
30 const parsed = recordArraySchema.safeParse(value);
31 return parsed.success ? parsed.data : [];
32}
33
26function buildOpenApiOperationParameters(34function buildOpenApiOperationParameters(
27 sharedParameters: Array<Record<string, unknown>>,35 sharedParameters: Array<Record<string, unknown>>,
28 operation: Record<string, unknown>,36 operation: Record<string, unknown>,
29): OpenApiOperationParameter[] {37): OpenApiOperationParameter[] {
30 return [38 return [
31 ...sharedParameters,39 ...sharedParameters,
32 ...(Array.isArray(operation.parameters)40 ...asRecordArray(operation.parameters),
33 ? (operation.parameters as Array<Record<string, unknown>>)
34 : []),
35 ].map((entry) => ({41 ].map((entry) => ({
36 name: String(entry.name ?? ""),42 name: String(entry.name ?? ""),
37 in: String(entry.in ?? "query"),43 in: String(entry.in ?? "query"),
@@ -63,15 +69,13 @@ export function buildOpenApiToolsFromPrepared( · 25 unmodified lines
63 69
64 for (const [pathTemplate, pathValue] of Object.entries(paths)) {70 for (const [pathTemplate, pathValue] of Object.entries(paths)) {
65 const pathObject = asRecord(pathValue);71 const pathObject = asRecord(pathValue);
66 const sharedParameters = Array.isArray(pathObject.parameters)72 const sharedParameters = asRecordArray(pathObject.parameters);
67 ? (pathObject.parameters as Array<Record<string, unknown>>)
68 : [];
69 73
70 for (const method of methods) {74 for (const method of methods) {
71 const operation = asRecord(pathObject[method]);75 const operation = asRecord(pathObject[method]);
72 if (Object.keys(operation).length === 0) continue;76 if (Object.keys(operation).length === 0) continue;
73 77
74 const tags = Array.isArray(operation.tags) ? (operation.tags as unknown[]) : [];78 const tags = Array.isArray(operation.tags) ? operation.tags : [];
75 const tagRaw = String(tags[0] ?? "default");79 const tagRaw = String(tags[0] ?? "default");
76 const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`);80 const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`);
77 const parameters = buildOpenApiOperationParameters(sharedParameters, operation);81 const parameters = buildOpenApiOperationParameters(sharedParameters, operation);
executor/packages/core/src/postman-runtime.ts+16−5
@@ -1,4 +1,5 @@
1import { asStringRecord, detectJsonContentType, findUnresolvedPostmanTemplateKeys, interpolatePostmanTemplate, stringifyTemplateValue } from "./postman-utils";1import { asStringRecord, detectJsonContentType, findUnresolvedPostmanTemplateKeys, interpolatePostmanTemplate, stringifyTemplateValue } from "./postman-utils";
2import { z } from "zod";
2import { asRecord } from "./utils";3import { asRecord } from "./utils";
3import type { PostmanRequestBody } from "./postman/collection-utils";4import type { PostmanRequestBody } from "./postman/collection-utils";
4 5
@@ -13,14 +14,24 @@ export interface PostmanSerializedRunSpec { · 8 unmodified lines
13 authHeaders: Record<string, string>;14 authHeaders: Record<string, string>;
14}15}
15 16
17const postmanRuntimePayloadSchema = z.object({
18 variables: z.record(z.unknown()).optional(),
19 query: z.record(z.unknown()).optional(),
20 headers: z.record(z.unknown()).optional(),
21 body: z.unknown().optional(),
22}).passthrough();
23
16export async function executePostmanRequest(24export async function executePostmanRequest(
17 runSpec: PostmanSerializedRunSpec,25 runSpec: PostmanSerializedRunSpec,
18 payload: Record<string, unknown>,26 payload: Record<string, unknown>,
19 credentialHeaders?: Record<string, string>,27 credentialHeaders?: Record<string, string>,
20): Promise<unknown> {28): Promise<unknown> {
29 const parsedPayload = postmanRuntimePayloadSchema.safeParse(payload);
30 const normalizedPayload = parsedPayload.success ? parsedPayload.data : {};
31
21 const variables = {32 const variables = {
22 ...runSpec.variables,33 ...runSpec.variables,
23 ...asStringRecord(payload.variables),34 ...asStringRecord(normalizedPayload.variables),
24 };35 };
25 36
26 const interpolatedUrl = interpolatePostmanTemplate(runSpec.url, variables);37 const interpolatedUrl = interpolatePostmanTemplate(runSpec.url, variables);
@@ -44,7 +55,7 @@ export async function executePostmanRequest( · 17 unmodified lines
44 }55 }
45 }56 }
46 57
47 const queryOverrides = asRecord(payload.query);58 const queryOverrides = asRecord(normalizedPayload.query);
48 for (const [key, value] of Object.entries(queryOverrides)) {59 for (const [key, value] of Object.entries(queryOverrides)) {
49 if (!key || value === undefined || value === null) continue;60 if (!key || value === undefined || value === null) continue;
50 url.searchParams.set(key, stringifyTemplateValue(value));61 url.searchParams.set(key, stringifyTemplateValue(value));
@@ -58,7 +69,7 @@ export async function executePostmanRequest( · 7 unmodified lines
58 Object.assign(headers, runSpec.authHeaders);69 Object.assign(headers, runSpec.authHeaders);
59 Object.assign(headers, credentialHeaders ?? {});70 Object.assign(headers, credentialHeaders ?? {});
60 71
61 const headerOverrides = asRecord(payload.headers);72 const headerOverrides = asRecord(normalizedPayload.headers);
62 for (const [name, value] of Object.entries(headerOverrides)) {73 for (const [name, value] of Object.entries(headerOverrides)) {
63 if (!name || value === undefined || value === null) continue;74 if (!name || value === undefined || value === null) continue;
64 headers[name] = stringifyTemplateValue(value);75 headers[name] = stringifyTemplateValue(value);
@@ -69,9 +80,9 @@ export async function executePostmanRequest( · 4 unmodified lines
69 let body: string | undefined;80 let body: string | undefined;
70 81
71 if (!readMethods.has(method)) {82 if (!readMethods.has(method)) {
72 const hasExplicitBody = Object.prototype.hasOwnProperty.call(payload, "body");83 const hasExplicitBody = Object.prototype.hasOwnProperty.call(normalizedPayload, "body");
73 if (hasExplicitBody) {84 if (hasExplicitBody) {
74 const bodyValue = payload.body;85 const bodyValue = normalizedPayload.body;
75 if (typeof bodyValue === "string") {86 if (typeof bodyValue === "string") {
76 body = bodyValue;87 body = bodyValue;
77 } else if (bodyValue !== undefined) {88 } else if (bodyValue !== undefined) {
executor/packages/core/src/postman-utils.ts+3−2
@@ -1,3 +1,5 @@
1import { asRecord } from "./utils";
2
1const POSTMAN_TEMPLATE_PATTERN = /\{\{([^{}]+)\}\}/g;3const POSTMAN_TEMPLATE_PATTERN = /\{\{([^{}]+)\}\}/g;
2 4
3export function stringifyTemplateValue(value: unknown): string {5export function stringifyTemplateValue(value: unknown): string {
@@ -26,9 +28,8 @@ export function findUnresolvedPostmanTemplateKeys(value: string): string[] { · 22 unmodified lines
26}28}
27 29
28export function asStringRecord(value: unknown): Record<string, string> {30export function asStringRecord(value: unknown): Record<string, string> {
29 if (!value || typeof value !== "object" || Array.isArray(value)) return {};
30 const result: Record<string, string> = {};31 const result: Record<string, string> = {};
31 for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {32 for (const [key, entry] of Object.entries(asRecord(value))) {
32 result[key] = stringifyTemplateValue(entry);33 result[key] = stringifyTemplateValue(entry);
33 }34 }
34 return result;35 return result;
executor/packages/core/src/postman/collection-utils.ts+5−8
@@ -1,5 +1,6 @@
1import { sanitizeSegment, sanitizeSnakeSegment } from "../tool/path-utils";1import { sanitizeSegment, sanitizeSnakeSegment } from "../tool/path-utils";
2import { stringifyTemplateValue } from "../postman-utils";2import { stringifyTemplateValue } from "../postman-utils";
3import { asRecord } from "../utils";
3 4
4export type PostmanRequestBody =5export type PostmanRequestBody =
5 | { kind: "urlencoded"; entries: Array<{ key: string; value: string }> }6 | { kind: "urlencoded"; entries: Array<{ key: string; value: string }> }
@@ -50,8 +51,7 @@ export function extractPostmanVariableMap(value: unknown): Record<string, string · 44 unmodified lines
50 if (!Array.isArray(value)) return {};51 if (!Array.isArray(value)) return {};
51 const result: Record<string, string> = {};52 const result: Record<string, string> = {};
52 for (const entry of value) {53 for (const entry of value) {
53 if (!entry || typeof entry !== "object") continue;54 const record = asRecord(entry);
54 const record = entry as Record<string, unknown>;
55 const key = typeof record.key === "string" ? record.key.trim() : "";55 const key = typeof record.key === "string" ? record.key.trim() : "";
56 if (!key) continue;56 if (!key) continue;
57 if (record.disabled === true) continue;57 if (record.disabled === true) continue;
@@ -64,8 +64,7 @@ export function extractPostmanHeaderMap(value: unknown): Record<string, string> · 6 unmodified lines
64 if (!Array.isArray(value)) return {};64 if (!Array.isArray(value)) return {};
65 const result: Record<string, string> = {};65 const result: Record<string, string> = {};
66 for (const entry of value) {66 for (const entry of value) {
67 if (!entry || typeof entry !== "object") continue;67 const record = asRecord(entry);
68 const record = entry as Record<string, unknown>;
69 const key = typeof record.key === "string" ? record.key.trim() : "";68 const key = typeof record.key === "string" ? record.key.trim() : "";
70 if (!key || record.disabled === true) continue;69 if (!key || record.disabled === true) continue;
71 result[key] = stringifyTemplateValue(record.value);70 result[key] = stringifyTemplateValue(record.value);
@@ -77,8 +76,7 @@ export function extractPostmanQueryEntries(value: unknown): Array<{ key: string; · 5 unmodified lines
77 if (!Array.isArray(value)) return [];76 if (!Array.isArray(value)) return [];
78 const entries: Array<{ key: string; value: string }> = [];77 const entries: Array<{ key: string; value: string }> = [];
79 for (const entry of value) {78 for (const entry of value) {
80 if (!entry || typeof entry !== "object") continue;79 const record = asRecord(entry);
81 const record = entry as Record<string, unknown>;
82 const key = typeof record.key === "string" ? record.key.trim() : "";80 const key = typeof record.key === "string" ? record.key.trim() : "";
83 if (!key || record.disabled === true) continue;81 if (!key || record.disabled === true) continue;
84 entries.push({ key, value: stringifyTemplateValue(record.value) });82 entries.push({ key, value: stringifyTemplateValue(record.value) });
@@ -91,8 +89,7 @@ export function extractPostmanBody(record: Record<string, unknown>): PostmanRequ · 6 unmodified lines
91 if (dataMode === "urlencoded" && Array.isArray(record.data)) {89 if (dataMode === "urlencoded" && Array.isArray(record.data)) {
92 const entries: Array<{ key: string; value: string }> = [];90 const entries: Array<{ key: string; value: string }> = [];
93 for (const item of record.data) {91 for (const item of record.data) {
94 if (!item || typeof item !== "object") continue;92 const entry = asRecord(item);
95 const entry = item as Record<string, unknown>;
96 const key = typeof entry.key === "string" ? entry.key.trim() : "";93 const key = typeof entry.key === "string" ? entry.key.trim() : "";
97 if (!key || entry.disabled === true) continue;94 if (!key || entry.disabled === true) continue;
98 entries.push({ key, value: stringifyTemplateValue(entry.value) });95 entries.push({ key, value: stringifyTemplateValue(entry.value) });
executor/packages/core/src/runtimes/runtime-core.ts+1−1
@@ -48,7 +48,7 @@ function sanitizeExecutionResult(value: unknown): unknown { · 47 unmodified lines
48 return null;48 return null;
49 }49 }
50 50
51 return Result.try(() => JSON.parse(serialized.value) as unknown).unwrapOr(String(value));51 return Result.try(() => JSON.parse(serialized.value)).unwrapOr(String(value));
52}52}
53 53
54function createToolsProxy(54function createToolsProxy(
executor/packages/core/src/tool-discovery.ts+35−11
@@ -1,4 +1,5 @@
1import type { ToolDefinition } from "./types";1import type { ToolDefinition } from "./types";
2import { z } from "zod";
2import {3import {
3 compactDescriptionLine,4 compactDescriptionLine,
4} from "./type-hints";5} from "./type-hints";
@@ -11,6 +12,23 @@ import { buildIndex, getTopLevelNamespace, listIndexForContext } from "./tool-di · 6 unmodified lines
11import { chooseBestPath, deriveIntentPhrase, extractNamespaceHints, scoreEntry } from "./tool-discovery/ranking";12import { chooseBestPath, deriveIntentPhrase, extractNamespaceHints, scoreEntry } from "./tool-discovery/ranking";
12import { asRecord } from "./utils";13import { asRecord } from "./utils";
13 14
15const catalogToolsInputSchema = z.object({
16 namespace: z.string().optional(),
17 query: z.string().optional(),
18 depth: z.coerce.number().optional(),
19 limit: z.coerce.number().optional(),
20 compact: z.boolean().optional(),
21 includeSchemas: z.boolean().optional(),
22});
23
24const discoverInputSchema = z.object({
25 query: z.string().optional(),
26 depth: z.coerce.number().optional(),
27 limit: z.coerce.number().optional(),
28 compact: z.boolean().optional(),
29 includeSchemas: z.boolean().optional(),
30});
31
14export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] {32export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] {
15 const index = buildIndex(tools);33 const index = buildIndex(tools);
16 34
@@ -70,12 +88,15 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 53 unmodified lines
70 },88 },
71 run: async (input: unknown, context) => {89 run: async (input: unknown, context) => {
72 const payload = asRecord(input);90 const payload = asRecord(input);
73 const namespaceFilter = String(payload.namespace ?? "").trim().toLowerCase();91 const parsedInput = catalogToolsInputSchema.safeParse(payload);
74 const query = String(payload.query ?? "").trim().toLowerCase();92 const namespaceFilter = (parsedInput.success ? (parsedInput.data.namespace ?? "") : "").trim().toLowerCase();
75 const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));93 const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase();
76 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));94 const depthValue = parsedInput.success ? parsedInput.data.depth : undefined;
77 const compact = payload.compact === false ? false : true;95 const depth = Math.max(0, Math.min(2, Number(depthValue ?? 1)));
78 const includeSchemas = payload.includeSchemas === true;96 const limitValue = parsedInput.success ? parsedInput.data.limit : undefined;
97 const limit = Math.max(1, Math.min(200, Number(limitValue ?? 50)));
98 const compact = parsedInput.success ? (parsedInput.data.compact ?? true) : true;
99 const includeSchemas = parsedInput.success ? (parsedInput.data.includeSchemas ?? false) : false;
79 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];100 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];
80 101
81 const visible = listIndexForContext(index, context.isToolAllowed);102 const visible = listIndexForContext(index, context.isToolAllowed);
@@ -157,11 +178,14 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 75 unmodified lines
157 },178 },
158 run: async (input: unknown, context) => {179 run: async (input: unknown, context) => {
159 const payload = asRecord(input);180 const payload = asRecord(input);
160 const query = String(payload.query ?? "").trim().toLowerCase();181 const parsedInput = discoverInputSchema.safeParse(payload);
161 const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));182 const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase();
162 const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));183 const depthValue = parsedInput.success ? parsedInput.data.depth : undefined;
163 const compact = payload.compact === false ? false : true;184 const depth = Math.max(0, Math.min(2, Number(depthValue ?? 1)));
164 const includeSchemas = payload.includeSchemas === true;185 const limitValue = parsedInput.success ? parsedInput.data.limit : undefined;
186 const limit = Math.max(1, Math.min(50, Number(limitValue ?? 8)));
187 const compact = parsedInput.success ? (parsedInput.data.compact ?? true) : true;
188 const includeSchemas = parsedInput.success ? (parsedInput.data.includeSchemas ?? false) : false;
165 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];189 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];
166 const namespaces = new Set(index.map((entry) => getTopLevelNamespace(entry.path)).filter(Boolean));190 const namespaces = new Set(index.map((entry) => getTopLevelNamespace(entry.path)).filter(Boolean));
167 const namespaceHints = extractNamespaceHints(terms, namespaces);191 const namespaceHints = extractNamespaceHints(terms, namespaces);
executor/packages/core/src/tool-discovery/indexing.ts+2−6
@@ -105,12 +105,8 @@ export function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] { · 104 unmodified lines
105 const aliases = getPathAliases(tool.path);105 const aliases = getPathAliases(tool.path);
106 const searchText = `${tool.path} ${preferredPath} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();106 const searchText = `${tool.path} ${preferredPath} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();
107 107
108 const inputSchema = (tool.typing?.inputSchema && typeof tool.typing.inputSchema === "object")108 const inputSchema = asRecord(tool.typing?.inputSchema);
109 ? tool.typing.inputSchema109 const outputSchema = asRecord(tool.typing?.outputSchema);
110 : {};
111 const outputSchema = (tool.typing?.outputSchema && typeof tool.typing.outputSchema === "object")
112 ? tool.typing.outputSchema
113 : {};
114 110
115 const safeInputSchema = sanitizeJsonSchemaForConvex(inputSchema);111 const safeInputSchema = sanitizeJsonSchemaForConvex(inputSchema);
116 const safeOutputSchema = sanitizeJsonSchemaForConvex(outputSchema);112 const safeOutputSchema = sanitizeJsonSchemaForConvex(outputSchema);
executor/packages/core/src/tool-source-loaders/graphql-loader.ts+98−14
@@ -1,5 +1,7 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";
4import { z } from "zod";
3import {5import {
4 buildFieldQuery,6 buildFieldQuery,
5 normalizeGraphqlFieldVariables,7 normalizeGraphqlFieldVariables,
@@ -19,6 +21,57 @@ import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/sc · 13 unmodified lines
19import type { ToolDefinition } from "../types";21import type { ToolDefinition } from "../types";
20import { asRecord } from "../utils";22import { asRecord } from "../utils";
21 23
24const gqlTypeRefSchema: z.ZodType<GqlTypeRef> = z.lazy(() => z.object({
25 kind: z.string(),
26 name: z.string().nullable(),
27 ofType: gqlTypeRefSchema.nullish(),
28}));
29
30const gqlFieldArgSchema = z.object({
31 name: z.string(),
32 description: z.string().nullable().optional().transform((value) => value ?? null),
33 type: gqlTypeRefSchema,
34 defaultValue: z.string().nullable().optional().transform((value) => value ?? null),
35});
36
37const gqlFieldSchema = z.object({
38 name: z.string(),
39 description: z.string().nullable().optional().transform((value) => value ?? null),
40 args: z.array(gqlFieldArgSchema),
41 type: gqlTypeRefSchema,
42});
43
44const gqlInputFieldSchema = z.object({
45 name: z.string(),
46 description: z.string().nullable().optional().transform((value) => value ?? null),
47 type: gqlTypeRefSchema,
48 defaultValue: z.string().nullable().optional().transform((value) => value ?? null),
49});
50
51const gqlEnumValueSchema = z.object({
52 name: z.string(),
53 description: z.string().nullable().optional().transform((value) => value ?? null),
54});
55
56const gqlTypeSchema: z.ZodType<GqlType> = z.object({
57 kind: z.string(),
58 name: z.string(),
59 fields: z.array(gqlFieldSchema).nullable().optional().transform((value) => value ?? null),
60 inputFields: z.array(gqlInputFieldSchema).nullable().optional().transform((value) => value ?? null),
61 enumValues: z.array(gqlEnumValueSchema).nullable().optional().transform((value) => value ?? null),
62});
63
64const gqlSchemaSchema: z.ZodType<GqlSchema> = z.object({
65 queryType: z.object({ name: z.string() }).nullable().optional().transform((value) => value ?? null),
66 mutationType: z.object({ name: z.string() }).nullable().optional().transform((value) => value ?? null),
67 types: z.array(gqlTypeSchema),
68});
69
70const gqlIntrospectionResponseSchema = z.object({
71 data: z.object({ __schema: gqlSchemaSchema }).optional(),
72 errors: z.array(z.unknown()).optional(),
73});
74
22const INTROSPECTION_QUERY = `75const INTROSPECTION_QUERY = `
23 query IntrospectionQuery {76 query IntrospectionQuery {
24 __schema {77 __schema {
@@ -147,6 +200,33 @@ function buildArgsObjectSchema( · 122 unmodified lines
147 };200 };
148}201}
149 202
203function parseGraphqlIntrospectionSchema(payload: unknown): Result<GqlSchema, Error> {
204 const parsed = gqlIntrospectionResponseSchema.safeParse(payload);
205 if (!parsed.success) {
206 return Result.err(new Error(`GraphQL introspection payload invalid: ${parsed.error.message}`));
207 }
208
209 const errors = parsed.data.errors;
210 if (Array.isArray(errors) && errors.length > 0) {
211 return Result.err(new Error(`GraphQL introspection errors: ${JSON.stringify(errors).slice(0, 500)}`));
212 }
213
214 const schema = parsed.data.data?.__schema;
215 if (!schema) {
216 return Result.err(new Error("GraphQL introspection returned no schema"));
217 }
218
219 return Result.ok(schema);
220}
221
222function toGraphqlEnvelope(value: unknown): GraphqlExecutionEnvelope {
223 const payload = asRecord(value);
224 return {
225 data: Object.prototype.hasOwnProperty.call(payload, "data") ? payload.data : null,
226 errors: Array.isArray(payload.errors) ? payload.errors : [],
227 };
228}
229
150export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> {230export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> {
151 const authHeaders = buildStaticAuthHeaders(config.auth);231 const authHeaders = buildStaticAuthHeaders(config.auth);
152 const sourceKey = `graphql:${config.name}`;232 const sourceKey = `graphql:${config.name}`;
@@ -168,14 +248,19 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 15 unmodified lines
168 throw new Error(`GraphQL introspection failed: HTTP ${introspectionResult.status}: ${text.slice(0, 300)}`);248 throw new Error(`GraphQL introspection failed: HTTP ${introspectionResult.status}: ${text.slice(0, 300)}`);
169 }249 }
170 250
171 const introspectionJson = (await introspectionResult.json()) as { data?: { __schema?: GqlSchema }; errors?: unknown[] };251 const introspectionJsonResult = await Result.tryPromise(() => introspectionResult.json());
172 if (introspectionJson.errors) {252 if (introspectionJsonResult.isErr()) {
173 throw new Error(`GraphQL introspection errors: ${JSON.stringify(introspectionJson.errors).slice(0, 500)}`);253 const message = introspectionJsonResult.error.cause instanceof Error
254 ? introspectionJsonResult.error.cause.message
255 : String(introspectionJsonResult.error.cause);
256 throw new Error(`GraphQL introspection response parse failed: ${message}`);
174 }257 }
175 const schema = introspectionJson.data?.__schema;258
176 if (!schema) {259 const schemaResult = parseGraphqlIntrospectionSchema(introspectionJsonResult.value);
177 throw new Error("GraphQL introspection returned no schema");260 if (schemaResult.isErr()) {
261 throw schemaResult.error;
178 }262 }
263 const schema = schemaResult.value;
179 264
180 // Index types by name265 // Index types by name
181 const typeMap = new Map<string, GqlType>();266 const typeMap = new Map<string, GqlType>();
@@ -187,7 +272,7 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 5 unmodified lines
187 272
188 // Create the main graphql tool — this is the one that actually executes queries273 // Create the main graphql tool — this is the one that actually executes queries
189 const mainToolPath = `${sourceName}.graphql`;274 const mainToolPath = `${sourceName}.graphql`;
190 tools.push({275 const mainTool: ToolDefinition & { _graphqlSource: string } = {
191 path: mainToolPath,276 path: mainToolPath,
192 source: sourceKey,277 source: sourceKey,
193 description: `Execute a GraphQL query or mutation against ${config.name}. Returns { data, errors }. Use ${sourceName}.query.* and ${sourceName}.mutation.* helpers when available.`,278 description: `Execute a GraphQL query or mutation against ${config.name}. Returns { data, errors }. Use ${sourceName}.query.* and ${sourceName}.mutation.* helpers when available.`,
@@ -236,7 +321,8 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 42 unmodified lines
236 context.credential?.headers,321 context.credential?.headers,
237 );322 );
238 },323 },
239 } as ToolDefinition & { _graphqlSource: string });324 };
325 tools.push(mainTool);
240 326
241 // Create pseudo-tools for each query/mutation field — these are for discovery/intellisense327 // Create pseudo-tools for each query/mutation field — these are for discovery/intellisense
242 // but they all route through the main .graphql tool328 // but they all route through the main .graphql tool
@@ -269,7 +355,7 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 26 unmodified lines
269 ? `tools.${fieldPath}({})`355 ? `tools.${fieldPath}({})`
270 : `tools.${fieldPath}({ ${field.args.map((arg) => `${arg.name}: ...`).join(", ")} })`;356 : `tools.${fieldPath}({ ${field.args.map((arg) => `${arg.name}: ...`).join(", ")} })`;
271 357
272 tools.push({358 const fieldTool: ToolDefinition & { _pseudoTool: boolean } = {
273 path: fieldPath,359 path: fieldPath,
274 source: sourceKey,360 source: sourceKey,
275 description: field.description361 description: field.description
@@ -315,13 +401,11 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 39 unmodified lines
315 );401 );
316 }402 }
317 }403 }
318 // Find and invoke the main tool404 const envelope = toGraphqlEnvelope(await mainTool.run(payload, context));
319 const mainTool = tools.find((t) => t.path === mainToolPath);
320 if (!mainTool) throw new Error("Main GraphQL tool not found");
321 const envelope = await mainTool.run(payload, context) as GraphqlExecutionEnvelope;
322 return selectGraphqlFieldEnvelope(envelope, field.name);405 return selectGraphqlFieldEnvelope(envelope, field.name);
323 },406 },
324 } as ToolDefinition & { _pseudoTool: boolean });407 };
408 tools.push(fieldTool);
325 }409 }
326 }410 }
327 411
executor/packages/core/src/tool-source-loaders/mcp-loader.ts+15−3
@@ -1,5 +1,6 @@
1"use node";1"use node";
2 2
3import { z } from "zod";
3import { connectMcp, extractMcpResult } from "../mcp-runtime";4import { connectMcp, extractMcpResult } from "../mcp-runtime";
4import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";5import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
5import { callMcpToolWithReconnect } from "../tool/source-execution";6import { callMcpToolWithReconnect } from "../tool/source-execution";
@@ -10,6 +11,19 @@ import type { ToolDefinition } from "../types"; · 4 unmodified lines
10import { asRecord } from "../utils";11import { asRecord } from "../utils";
11import type { SerializedTool } from "../tool/source-serialization";12import type { SerializedTool } from "../tool/source-serialization";
12 13
14const listedToolsResponseSchema = z.object({
15 tools: z.array(z.record(z.unknown())).optional(),
16});
17
18function extractListedTools(value: unknown): Record<string, unknown>[] {
19 const parsed = listedToolsResponseSchema.safeParse(value);
20 if (!parsed.success) {
21 return [];
22 }
23
24 return parsed.data.tools ?? [];
25}
26
13export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDefinition[]> {27export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDefinition[]> {
14 const queryParams = config.queryParams28 const queryParams = config.queryParams
15 ? Object.fromEntries(29 ? Object.fromEntries(
@@ -50,9 +64,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 34 unmodified lines
50 }64 }
51 65
52 const listed = await connection.client.listTools();66 const listed = await connection.client.listTools();
53 const tools = Array.isArray((listed as { tools?: unknown }).tools)67 const tools = extractListedTools(listed);
54 ? ((listed as { tools: Array<Record<string, unknown>> }).tools)
55 : [];
56 68
57 return tools.map((tool) => {69 return tools.map((tool) => {
58 const toolName = String(tool.name ?? "tool");70 const toolName = String(tool.name ?? "tool");
executor/packages/core/src/tool-source-loaders/openapi-loader.ts+26−8
@@ -1,5 +1,7 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";
4import { z } from "zod";
3import { buildOpenApiToolsFromPrepared } from "../openapi/tool-builder";5import { buildOpenApiToolsFromPrepared } from "../openapi/tool-builder";
4import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";6import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
5import {7import {
@@ -19,6 +21,14 @@ import type { SerializedTool } from "../tool/source-serialization"; · 13 unmodified lines
19 21
20const POSTMAN_SPEC_PREFIX = "postman:";22const POSTMAN_SPEC_PREFIX = "postman:";
21const DEFAULT_POSTMAN_PROXY_URL = "https://www.postman.com/_api/ws/proxy";23const DEFAULT_POSTMAN_PROXY_URL = "https://www.postman.com/_api/ws/proxy";
24const recordArraySchema = z.array(z.record(z.unknown()));
25const postmanCollectionResponseSchema = z.object({
26 data: z.object({
27 requests: z.array(z.record(z.unknown())).optional(),
28 folders: z.array(z.record(z.unknown())).optional(),
29 variables: z.unknown().optional(),
30 }).optional(),
31});
22 32
23function parsePostmanCollectionUid(spec: string): string | null {33function parsePostmanCollectionUid(spec: string): string | null {
24 if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) {34 if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) {
@@ -57,15 +67,23 @@ async function loadPostmanCollectionTools( · 32 unmodified lines
57 throw new Error(`Failed to fetch API collection ${collectionUid}: HTTP ${response.status} ${text.slice(0, 300)}`);67 throw new Error(`Failed to fetch API collection ${collectionUid}: HTTP ${response.status} ${text.slice(0, 300)}`);
58 }68 }
59 69
60 const raw = await response.json() as Record<string, unknown>;70 const responseJsonResult = await Result.tryPromise(() => response.json());
61 const collection = asRecord(raw.data);71 if (responseJsonResult.isErr()) {
62 const requests = Array.isArray(collection.requests)72 const cause = responseJsonResult.error.cause;
63 ? collection.requests.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object"))73 const message = cause instanceof Error ? cause.message : String(cause);
64 : [];74 throw new Error(`Failed to parse API collection ${collectionUid}: ${message}`);
75 }
76
77 const parsedCollection = postmanCollectionResponseSchema.safeParse(responseJsonResult.value);
78 if (!parsedCollection.success) {
79 throw new Error(`Invalid API collection response for ${collectionUid}: ${parsedCollection.error.message}`);
80 }
65 81
66 const folders = Array.isArray(collection.folders)82 const collection = parsedCollection.data.data ?? {};
67 ? collection.folders.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object"))83 const requestsResult = recordArraySchema.safeParse(collection.requests);
68 : [];84 const requests = requestsResult.success ? requestsResult.data : [];
85 const foldersResult = recordArraySchema.safeParse(collection.folders);
86 const folders = foldersResult.success ? foldersResult.data : [];
69 87
70 const folderById = new Map<string, { name: string; parentId?: string }>();88 const folderById = new Map<string, { name: string; parentId?: string }>();
71 for (const folder of folders) {89 for (const folder of folders) {
executor/packages/core/src/tool-sources.ts+61−1
@@ -1,10 +1,12 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";
4import { z } from "zod";
3import { loadGraphqlTools } from "./tool-source-loaders/graphql-loader";5import { loadGraphqlTools } from "./tool-source-loaders/graphql-loader";
4import { loadMcpTools } from "./tool-source-loaders/mcp-loader";6import { loadMcpTools } from "./tool-source-loaders/mcp-loader";
5import { loadOpenApiTools } from "./tool-source-loaders/openapi-loader";7import { loadOpenApiTools } from "./tool-source-loaders/openapi-loader";
6import { buildOpenApiToolsFromPrepared } from "./openapi/tool-builder";8import { buildOpenApiToolsFromPrepared } from "./openapi/tool-builder";
7import { rehydrateTools, serializeTools, type SerializedTool } from "./tool/source-serialization";9import { parseSerializedTool, rehydrateTools, serializeTools, type SerializedTool } from "./tool/source-serialization";
8import type {10import type {
9 ExternalToolSourceConfig,11 ExternalToolSourceConfig,
10 OpenApiToolSourceConfig,12 OpenApiToolSourceConfig,
@@ -32,6 +34,64 @@ export interface CompiledToolSourceArtifact { · 21 unmodified lines
32 tools: SerializedTool[];34 tools: SerializedTool[];
33}35}
34 36
37const compiledToolSourceArtifactSchema = z.object({
38 version: z.literal("v1"),
39 sourceType: z.enum(["mcp", "openapi", "graphql"]),
40 sourceName: z.string(),
41 tools: z.array(z.unknown()),
42});
43
44const workspaceToolSnapshotSchema = z.object({
45 version: z.literal("v2"),
46 externalArtifacts: z.array(z.unknown()),
47 warnings: z.array(z.string()),
48});
49
50export function parseCompiledToolSourceArtifact(value: unknown): Result<CompiledToolSourceArtifact, Error> {
51 const parsedArtifact = compiledToolSourceArtifactSchema.safeParse(value);
52 if (!parsedArtifact.success) {
53 return Result.err(new Error(parsedArtifact.error.message));
54 }
55
56 const tools: SerializedTool[] = [];
57 for (const tool of parsedArtifact.data.tools) {
58 const parsedTool = parseSerializedTool(tool);
59 if (parsedTool.isErr()) {
60 return Result.err(new Error(`Invalid serialized tool in artifact '${parsedArtifact.data.sourceName}': ${parsedTool.error.message}`));
61 }
62 tools.push(parsedTool.value);
63 }
64
65 return Result.ok({
66 version: parsedArtifact.data.version,
67 sourceType: parsedArtifact.data.sourceType,
68 sourceName: parsedArtifact.data.sourceName,
69 tools,
70 });
71}
72
73export function parseWorkspaceToolSnapshot(value: unknown): Result<WorkspaceToolSnapshot, Error> {
74 const parsedSnapshot = workspaceToolSnapshotSchema.safeParse(value);
75 if (!parsedSnapshot.success) {
76 return Result.err(new Error(parsedSnapshot.error.message));
77 }
78
79 const externalArtifacts: CompiledToolSourceArtifact[] = [];
80 for (const artifact of parsedSnapshot.data.externalArtifacts) {
81 const parsedArtifact = parseCompiledToolSourceArtifact(artifact);
82 if (parsedArtifact.isErr()) {
83 return Result.err(new Error(`Invalid workspace snapshot artifact: ${parsedArtifact.error.message}`));
84 }
85 externalArtifacts.push(parsedArtifact.value);
86 }
87
88 return Result.ok({
89 version: parsedSnapshot.data.version,
90 externalArtifacts,
91 warnings: parsedSnapshot.data.warnings,
92 });
93}
94
35async function loadSourceToolDefinitions(source: ExternalToolSourceConfig): Promise<ToolDefinition[]> {95async function loadSourceToolDefinitions(source: ExternalToolSourceConfig): Promise<ToolDefinition[]> {
36 if (source.type === "mcp") {96 if (source.type === "mcp") {
37 return await loadMcpTools(source);97 return await loadMcpTools(source);
executor/packages/core/src/tool-typing/convex-sanitize.ts+2−1
@@ -1,4 +1,5 @@
1import type { JsonSchema } from "../types";1import type { JsonSchema } from "../types";
2import { asRecord } from "../utils";
2 3
3function stripDollarKeysDeep(value: unknown): unknown {4function stripDollarKeysDeep(value: unknown): unknown {
4 if (Array.isArray(value)) {5 if (Array.isArray(value)) {
@@ -7,7 +8,7 @@ function stripDollarKeysDeep(value: unknown): unknown { · 2 unmodified lines
7 8
8 if (value && typeof value === "object") {9 if (value && typeof value === "object") {
9 const out: Record<string, unknown> = {};10 const out: Record<string, unknown> = {};
10 for (const [key, child] of Object.entries(value as Record<string, unknown>)) {11 for (const [key, child] of Object.entries(asRecord(value))) {
11 if (key.startsWith("$")) continue;12 if (key.startsWith("$")) continue;
12 out[key] = stripDollarKeysDeep(child);13 out[key] = stripDollarKeysDeep(child);
13 }14 }
executor/packages/core/src/tool-typing/schema-utils.ts+17−16
@@ -5,31 +5,32 @@ function uniq(values: string[]): string[] { · 4 unmodified lines
5 return [...new Set(values.filter((v) => v.trim().length > 0))];5 return [...new Set(values.filter((v) => v.trim().length > 0))];
6}6}
7 7
8function collectTopLevelRequiredKeys(schema: JsonSchema, out: string[]): void {8function asUnknownArray(value: unknown): unknown[] {
9 const required = Array.isArray((schema as Record<string, unknown>).required)9 return Array.isArray(value) ? value : [];
10 ? ((schema as Record<string, unknown>).required as unknown[]).filter((v): v is string => typeof v === "string")10}
11 : [];11
12function collectTopLevelRequiredKeys(schema: Record<string, unknown>, out: string[]): void {
13 const required = asUnknownArray(schema.required)
14 .filter((value): value is string => typeof value === "string");
12 out.push(...required);15 out.push(...required);
13 16
14 const allOf = Array.isArray((schema as Record<string, unknown>).allOf)17 const allOf = asUnknownArray(schema.allOf);
15 ? ((schema as Record<string, unknown>).allOf as unknown[])
16 : [];
17 for (const entry of allOf) {18 for (const entry of allOf) {
18 if (!entry || typeof entry !== "object") continue;19 const nested = asRecord(entry);
19 collectTopLevelRequiredKeys(entry as JsonSchema, out);20 if (Object.keys(nested).length === 0) continue;
21 collectTopLevelRequiredKeys(nested, out);
20 }22 }
21}23}
22 24
23function collectTopLevelPropertyKeys(schema: JsonSchema, out: string[]): void {25function collectTopLevelPropertyKeys(schema: Record<string, unknown>, out: string[]): void {
24 const props = asRecord((schema as Record<string, unknown>).properties);26 const props = asRecord(schema.properties);
25 out.push(...Object.keys(props));27 out.push(...Object.keys(props));
26 28
27 const allOf = Array.isArray((schema as Record<string, unknown>).allOf)29 const allOf = asUnknownArray(schema.allOf);
28 ? ((schema as Record<string, unknown>).allOf as unknown[])
29 : [];
30 for (const entry of allOf) {30 for (const entry of allOf) {
31 if (!entry || typeof entry !== "object") continue;31 const nested = asRecord(entry);
32 collectTopLevelPropertyKeys(entry as JsonSchema, out);32 if (Object.keys(nested).length === 0) continue;
33 collectTopLevelPropertyKeys(nested, out);
33 }34 }
34}35}
35 36
executor/packages/core/src/tool-typing/typebundle.ts+2−1
@@ -1,6 +1,7 @@
1import type { ToolDefinition } from "../types";1import type { ToolDefinition } from "../types";
2import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";2import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
3import { OPENAPI_HELPER_TYPES } from "../openapi/helper-types";3import { OPENAPI_HELPER_TYPES } from "../openapi/helper-types";
4import { asRecord } from "../utils";
4import { BASE_ENVIRONMENT_DTS } from "./env-types";5import { BASE_ENVIRONMENT_DTS } from "./env-types";
5 6
6const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i;7const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i;
@@ -30,7 +31,7 @@ function wrapDtsInNamespace(namespace: string, rawDts: string): string { · 23 unmodified lines
30function typeHintFromSchema(schema: Record<string, unknown> | undefined, fallback: string): string {31function typeHintFromSchema(schema: Record<string, unknown> | undefined, fallback: string): string {
31 if (!schema || Object.keys(schema).length === 0) return fallback;32 if (!schema || Object.keys(schema).length === 0) return fallback;
32 // Special-case empty object input to keep signatures tidy.33 // Special-case empty object input to keep signatures tidy.
33 const props = schema.properties && typeof schema.properties === "object" ? schema.properties as Record<string, unknown> : {};34 const props = asRecord(schema.properties);
34 const required = Array.isArray(schema.required) ? schema.required : [];35 const required = Array.isArray(schema.required) ? schema.required : [];
35 if (Object.keys(props).length === 0 && required.length === 0) {36 if (Object.keys(props).length === 0 && required.length === 0) {
36 return fallback === "{}" ? "{}" : fallback;37 return fallback === "{}" ? "{}" : fallback;
executor/packages/core/src/tool/source-serialization.ts+150−7
@@ -2,6 +2,8 @@ import { connectMcp, extractMcpResult } from "../mcp-runtime"; · 1 unmodified line
2import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime";2import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime";
3import { normalizeGraphqlFieldVariables, selectGraphqlFieldEnvelope } from "../graphql/field-tools";3import { normalizeGraphqlFieldVariables, selectGraphqlFieldEnvelope } from "../graphql/field-tools";
4import { callMcpToolWithReconnect, executeGraphqlRequest, executeOpenApiRequest } from "./source-execution";4import { callMcpToolWithReconnect, executeGraphqlRequest, executeOpenApiRequest } from "./source-execution";
5import { Result } from "better-result";
6import { z } from "zod";
5import type { ToolApprovalMode, ToolCredentialSpec, ToolDefinition, ToolTyping } from "../types";7import type { ToolApprovalMode, ToolCredentialSpec, ToolDefinition, ToolTyping } from "../types";
6import { asRecord } from "../utils";8import { asRecord } from "../utils";
7 9
@@ -49,6 +51,116 @@ export interface SerializedTool { · 41 unmodified lines
49 | { kind: "builtin" };51 | { kind: "builtin" };
50}52}
51 53
54const openApiRunSpecSchema = z.object({
55 kind: z.literal("openapi"),
56 baseUrl: z.string(),
57 method: z.string(),
58 pathTemplate: z.string(),
59 parameters: z.array(z.object({
60 name: z.string(),
61 in: z.string(),
62 required: z.boolean(),
63 schema: z.record(z.unknown()),
64 })),
65 authHeaders: z.record(z.string()),
66});
67
68const mcpRunSpecSchema = z.object({
69 kind: z.literal("mcp"),
70 url: z.string(),
71 transport: z.enum(["sse", "streamable-http"]).optional(),
72 queryParams: z.record(z.string()).optional(),
73 authHeaders: z.record(z.string()),
74 toolName: z.string(),
75});
76
77const postmanRunSpecSchema: z.ZodType<PostmanSerializedRunSpec> = z.object({
78 kind: z.literal("postman"),
79 method: z.string(),
80 url: z.string(),
81 headers: z.record(z.string()),
82 queryParams: z.array(z.object({ key: z.string(), value: z.string() })),
83 body: z.union([
84 z.object({ kind: z.literal("urlencoded"), entries: z.array(z.object({ key: z.string(), value: z.string() })) }),
85 z.object({ kind: z.literal("raw"), text: z.string() }),
86 ]).optional(),
87 variables: z.record(z.string()),
88 authHeaders: z.record(z.string()),
89});
90
91const graphqlRawRunSpecSchema = z.object({
92 kind: z.literal("graphql_raw"),
93 endpoint: z.string(),
94 authHeaders: z.record(z.string()),
95});
96
97const graphqlFieldRunSpecSchema = z.object({
98 kind: z.literal("graphql_field"),
99 endpoint: z.string(),
100 operationName: z.string(),
101 operationType: z.enum(["query", "mutation"]),
102 queryTemplate: z.string(),
103 argNames: z.array(z.string()).optional(),
104 authHeaders: z.record(z.string()),
105});
106
107const builtinRunSpecSchema = z.object({ kind: z.literal("builtin") });
108
109const toolTypedRefSchema = z.object({
110 kind: z.literal("openapi_operation"),
111 sourceKey: z.string(),
112 operationId: z.string(),
113});
114
115const toolTypingSchema: z.ZodType<ToolTyping> = z.object({
116 inputSchema: z.record(z.unknown()).optional(),
117 outputSchema: z.record(z.unknown()).optional(),
118 inputHint: z.string().optional(),
119 outputHint: z.string().optional(),
120 requiredInputKeys: z.array(z.string()).optional(),
121 previewInputKeys: z.array(z.string()).optional(),
122 typedRef: toolTypedRefSchema.optional(),
123});
124
125const toolCredentialSpecSchema: z.ZodType<ToolCredentialSpec> = z.object({
126 sourceKey: z.string(),
127 mode: z.enum(["static", "workspace", "actor"]),
128 authType: z.enum(["bearer", "apiKey", "basic"]),
129 headerName: z.string().optional(),
130 staticSecretJson: z.record(z.unknown()).optional(),
131});
132
133const graphqlRawInputSchema = z.object({
134 query: z.string(),
135 variables: z.unknown().optional(),
136});
137
138const graphqlFieldInputSchema = z.object({
139 query: z.string().optional(),
140 variables: z.unknown().optional(),
141}).passthrough();
142
143const serializedRunSpecSchema = z.union([
144 openApiRunSpecSchema,
145 mcpRunSpecSchema,
146 postmanRunSpecSchema,
147 graphqlRawRunSpecSchema,
148 graphqlFieldRunSpecSchema,
149 builtinRunSpecSchema,
150]);
151
152const serializedToolSchema: z.ZodType<SerializedTool> = z.object({
153 path: z.string(),
154 description: z.string(),
155 approval: z.enum(["auto", "required"]),
156 source: z.string().optional(),
157 typing: toolTypingSchema.optional(),
158 credential: toolCredentialSpecSchema.optional(),
159 _graphqlSource: z.string().optional(),
160 _pseudoTool: z.boolean().optional(),
161 runSpec: serializedRunSpecSchema,
162});
163
52type ToolWithRunSpec = ToolDefinition & { _runSpec?: SerializedTool["runSpec"] };164type ToolWithRunSpec = ToolDefinition & { _runSpec?: SerializedTool["runSpec"] };
53type McpConnection = Awaited<ReturnType<typeof connectMcp>>;165type McpConnection = Awaited<ReturnType<typeof connectMcp>>;
54type McpConnectionCacheEntry = { promise: Promise<McpConnection> };166type McpConnectionCacheEntry = { promise: Promise<McpConnection> };
@@ -99,13 +211,41 @@ export function serializeTools(tools: ToolDefinition[]): SerializedTool[] { · 44 unmodified lines
99 }));211 }));
100}212}
101 213
214export function parseSerializedTool(value: unknown): Result<SerializedTool, Error> {
215 const parsed = serializedToolSchema.safeParse(value);
216 if (!parsed.success) {
217 return Result.err(new Error(parsed.error.message));
218 }
219
220 return Result.ok(parsed.data);
221}
222
102export function rehydrateTools(223export function rehydrateTools(
103 serialized: SerializedTool[],224 serialized: ReadonlyArray<unknown>,
104 baseTools: Map<string, ToolDefinition>,225 baseTools: Map<string, ToolDefinition>,
105): ToolDefinition[] {226): ToolDefinition[] {
106 const mcpConnections = new Map<string, McpConnectionCacheEntry>();227 const mcpConnections = new Map<string, McpConnectionCacheEntry>();
107 228
108 return serialized.map((st) => {229 return serialized.map((candidate, index) => {
230 const parsed = parseSerializedTool(candidate);
231 if (parsed.isErr()) {
232 const raw = asRecord(candidate);
233 const path = typeof raw.path === "string" && raw.path.trim().length > 0
234 ? raw.path
235 : `invalid_serialized_tool_${index + 1}`;
236
237 return {
238 path,
239 description: "Invalid serialized tool definition",
240 approval: "required",
241 source: typeof raw.source === "string" ? raw.source : undefined,
242 run: async () => {
243 throw new Error(`Invalid serialized tool '${path}': ${parsed.error.message}`);
244 },
245 };
246 }
247
248 const st = parsed.value;
109 const base: Omit<ToolDefinition, "run"> = {249 const base: Omit<ToolDefinition, "run"> = {
110 path: st.path,250 path: st.path,
111 description: st.description,251 description: st.description,
@@ -191,11 +331,12 @@ export function rehydrateTools( · 79 unmodified lines
191 ...base,331 ...base,
192 run: async (input: unknown, context) => {332 run: async (input: unknown, context) => {
193 const payload = asRecord(input);333 const payload = asRecord(input);
194 const query = typeof payload.query === "string" ? payload.query : "";334 const parsedInput = graphqlRawInputSchema.safeParse(payload);
335 const query = parsedInput.success ? parsedInput.data.query : "";
195 if (!query.trim()) {336 if (!query.trim()) {
196 throw new Error("GraphQL query string is required");337 throw new Error("GraphQL query string is required");
197 }338 }
198 const variables = payload.variables;339 const variables = parsedInput.success ? parsedInput.data.variables : undefined;
199 const response = await executeGraphqlRequest(endpoint, authHeaders, query, variables, context.credential?.headers);340 const response = await executeGraphqlRequest(endpoint, authHeaders, query, variables, context.credential?.headers);
200 if (response.isErr()) {341 if (response.isErr()) {
201 throw new Error(response.error.message);342 throw new Error(response.error.message);
@@ -211,10 +352,12 @@ export function rehydrateTools( · 9 unmodified lines
211 ...base,352 ...base,
212 run: async (input: unknown, context) => {353 run: async (input: unknown, context) => {
213 const payload = asRecord(input);354 const payload = asRecord(input);
214 const hasExplicitQuery = typeof payload.query === "string" && payload.query.trim().length > 0;355 const parsedInput = graphqlFieldInputSchema.safeParse(payload);
215 const query = hasExplicitQuery ? String(payload.query) : queryTemplate;356 const normalizedInput = parsedInput.success ? parsedInput.data : {};
357 const hasExplicitQuery = typeof normalizedInput.query === "string" && normalizedInput.query.trim().length > 0;
358 const query = hasExplicitQuery ? normalizedInput.query : queryTemplate;
216 359
217 let variables = payload.variables;360 let variables = normalizedInput.variables;
218 if (variables === undefined && !hasExplicitQuery) {361 if (variables === undefined && !hasExplicitQuery) {
219 variables = normalizeGraphqlFieldVariables(argNames ?? [], payload);362 variables = normalizeGraphqlFieldVariables(argNames ?? [], payload);
220 }363 }
executor/packages/core/src/workspace/tool-cache.test.ts+42−11
@@ -10,10 +10,10 @@ import { · 9 unmodified lines
10 prepareOpenApiSpec,10 prepareOpenApiSpec,
11 buildOpenApiToolsFromPrepared,11 buildOpenApiToolsFromPrepared,
12 materializeWorkspaceSnapshot,12 materializeWorkspaceSnapshot,
13 parseWorkspaceToolSnapshot,
13 serializeTools,14 serializeTools,
14 rehydrateTools,15 rehydrateTools,
15 type CompiledToolSourceArtifact,16 type CompiledToolSourceArtifact,
16 type SerializedTool,
17 type WorkspaceToolSnapshot,17 type WorkspaceToolSnapshot,
18} from "../tool-sources";18} from "../tool-sources";
19import type { ToolDefinition } from "../types";19import type { ToolDefinition } from "../types";
@@ -184,7 +184,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 164 unmodified lines
184 184
185 // JSON round-trip (simulates storage)185 // JSON round-trip (simulates storage)
186 const json = JSON.stringify(serialized);186 const json = JSON.stringify(serialized);
187 const restored = JSON.parse(json) as SerializedTool[];187 const restored = JSON.parse(json);
188 expect(restored.length).toBe(4);188 expect(restored.length).toBe(4);
189 189
190 // Rehydrate190 // Rehydrate
@@ -214,7 +214,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 23 unmodified lines
214 214
215 const serialized = serializeTools(tools);215 const serialized = serializeTools(tools);
216 const json = JSON.stringify(serialized);216 const json = JSON.stringify(serialized);
217 const restored = JSON.parse(json) as SerializedTool[];217 const restored = JSON.parse(json);
218 const rehydrated = rehydrateTools(restored, makeBaseTools());218 const rehydrated = rehydrateTools(restored, makeBaseTools());
219 219
220 for (let i = 0; i < tools.length; i++) {220 for (let i = 0; i < tools.length; i++) {
@@ -235,7 +235,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 14 unmodified lines
235 expect(serialized[0]!.runSpec.kind).toBe("builtin");235 expect(serialized[0]!.runSpec.kind).toBe("builtin");
236 236
237 const json = JSON.stringify(serialized);237 const json = JSON.stringify(serialized);
238 const restored = JSON.parse(json) as SerializedTool[];238 const restored = JSON.parse(json);
239 const rehydrated = rehydrateTools(restored, baseTools);239 const rehydrated = rehydrateTools(restored, baseTools);
240 240
241 expect(rehydrated.length).toBe(1);241 expect(rehydrated.length).toBe(1);
@@ -279,7 +279,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 37 unmodified lines
279 279
280 // JSON round-trip280 // JSON round-trip
281 const json = JSON.stringify(serialized);281 const json = JSON.stringify(serialized);
282 const restored = JSON.parse(json) as SerializedTool[];282 const restored = JSON.parse(json);
283 expect(restored[0]!.runSpec.kind).toBe("mcp");283 expect(restored[0]!.runSpec.kind).toBe("mcp");
284 });284 });
285 285
@@ -327,7 +327,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 41 unmodified lines
327 expect(serialized[0]!.runSpec.kind).toBe("graphql_raw");327 expect(serialized[0]!.runSpec.kind).toBe("graphql_raw");
328 expect(serialized[1]!.runSpec.kind).toBe("graphql_field");328 expect(serialized[1]!.runSpec.kind).toBe("graphql_field");
329 329
330 const restored = JSON.parse(JSON.stringify(serialized)) as SerializedTool[];330 const restored = JSON.parse(JSON.stringify(serialized));
331 const rehydrated = rehydrateTools(restored, makeBaseTools());331 const rehydrated = rehydrateTools(restored, makeBaseTools());
332 332
333 const rawTool = rehydrated.find((tool) => tool.path === "linear.graphql");333 const rawTool = rehydrated.find((tool) => tool.path === "linear.graphql");
@@ -401,13 +401,17 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 67 unmodified lines
401 const json = JSON.stringify(snapshot);401 const json = JSON.stringify(snapshot);
402 const blob = new Blob([json], { type: "application/json" });402 const blob = new Blob([json], { type: "application/json" });
403 const text = await blob.text();403 const text = await blob.text();
404 const restored = JSON.parse(text) as WorkspaceToolSnapshot;404 const restored = parseWorkspaceToolSnapshot(JSON.parse(text));
405 expect(restored.isOk()).toBe(true);
406 if (restored.isErr()) {
407 throw restored.error;
408 }
405 409
406 expect(restored.warnings).toEqual(["test warning"]);410 expect(restored.value.warnings).toEqual(["test warning"]);
407 expect(restored.version).toBe("v2");411 expect(restored.value.version).toBe("v2");
408 expect(restored.externalArtifacts).toHaveLength(1);412 expect(restored.value.externalArtifacts).toHaveLength(1);
409 413
410 const restoredTools = materializeWorkspaceSnapshot(restored);414 const restoredTools = materializeWorkspaceSnapshot(restored.value);
411 expect(restoredTools.length).toBe(tools.length);415 expect(restoredTools.length).toBe(tools.length);
412 expect(restoredTools.some((tool) => tool.path === "echo")).toBe(false);416 expect(restoredTools.some((tool) => tool.path === "echo")).toBe(false);
413 417
@@ -482,6 +486,33 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 68 unmodified lines
482 // Should be well under 1MB for 100 tools486 // Should be well under 1MB for 100 tools
483 expect(json.length).toBeLessThan(1_000_000);487 expect(json.length).toBeLessThan(1_000_000);
484 });488 });
489
490 test("invalid serialized tools fail with explicit erroring placeholder", async () => {
491 const invalidSerialized = [
492 {
493 path: "broken.tool",
494 description: "broken",
495 approval: "auto",
496 runSpec: {
497 kind: "mcp",
498 },
499 },
500 ];
501
502 const [tool] = rehydrateTools(invalidSerialized, makeBaseTools());
503 expect(tool.path).toBe("broken.tool");
504
505 await expect(
506 tool.run(
507 {},
508 {
509 taskId: "t",
510 workspaceId: TEST_WORKSPACE_ID,
511 isToolAllowed: () => true,
512 },
513 ),
514 ).rejects.toThrow("Invalid serialized tool 'broken.tool'");
515 });
485});516});
486 517
487describe("workspace tool cache table", () => {518describe("workspace tool cache table", () => {
executor/packages/database/src/auth/event_handlers.ts+22−11
@@ -1,4 +1,5 @@
1import { type AuthKit } from "@convex-dev/workos-authkit";1import { type AuthKit } from "@convex-dev/workos-authkit";
2import { z } from "zod";
2import type { DataModel, Doc } from "../../convex/_generated/dataModel.d.ts";3import type { DataModel, Doc } from "../../convex/_generated/dataModel.d.ts";
3import type { MutationCtx } from "../../convex/_generated/server";4import type { MutationCtx } from "../../convex/_generated/server";
4import { upsertWorkosAccount } from "./accounts";5import { upsertWorkosAccount } from "./accounts";
@@ -16,7 +17,18 @@ import { · 11 unmodified lines
16} from "./memberships";17} from "./memberships";
17import { ensureUniqueOrganizationSlug } from "./naming";18import { ensureUniqueOrganizationSlug } from "./naming";
18import { seedWorkspaceMembersFromOrganization } from "./workspace_membership_projection";19import { seedWorkspaceMembersFromOrganization } from "./workspace_membership_projection";
19import { asPayload } from "../lib/object";20
21const trimmedStringSchema = z.string().transform((value) => value.trim());
22
23const workosMembershipEventDataSchema = z.object({
24 id: trimmedStringSchema.refine((value) => value.length > 0),
25 user_id: trimmedStringSchema.optional(),
26 userId: trimmedStringSchema.optional(),
27 organization_id: trimmedStringSchema.optional(),
28 organizationId: trimmedStringSchema.optional(),
29 role: z.object({ slug: trimmedStringSchema.optional() }).optional(),
30 status: trimmedStringSchema.optional(),
31});
20 32
21type WorkosMembershipEventData = {33type WorkosMembershipEventData = {
22 id: string;34 id: string;
@@ -35,20 +47,19 @@ type ResolvedMembershipTarget = { · 12 unmodified lines
35};47};
36 48
37function parseMembershipEventData(value: unknown): WorkosMembershipEventData | null {49function parseMembershipEventData(value: unknown): WorkosMembershipEventData | null {
38 const record = asPayload(value);50 const parsed = workosMembershipEventDataSchema.safeParse(value);
39 if (typeof record.id !== "string" || record.id.trim().length === 0) {51 if (!parsed.success) {
40 return null;52 return null;
41 }53 }
42 54
43 const role = asPayload(record.role);
44 return {55 return {
45 id: record.id,56 id: parsed.data.id,
46 user_id: typeof record.user_id === "string" ? record.user_id : undefined,57 user_id: parsed.data.user_id,
47 userId: typeof record.userId === "string" ? record.userId : undefined,58 userId: parsed.data.userId,
48 organization_id: typeof record.organization_id === "string" ? record.organization_id : undefined,59 organization_id: parsed.data.organization_id,
49 organizationId: typeof record.organizationId === "string" ? record.organizationId : undefined,60 organizationId: parsed.data.organizationId,
50 role: typeof role.slug === "string" ? { slug: role.slug } : undefined,61 role: parsed.data.role?.slug ? { slug: parsed.data.role.slug } : undefined,
51 status: typeof record.status === "string" ? record.status : undefined,62 status: parsed.data.status,
52 };63 };
53}64}
54 65
executor/packages/database/src/database/tool_source_config.test.ts+30−0
@@ -49,3 +49,33 @@ test("normalizeExternalToolSource returns error result with source context", () · 48 unmodified lines
49 );49 );
50 }50 }
51});51});
52
53test("normalizeToolSourceConfig validates string maps with field-level errors", () => {
54 const result = normalizeToolSourceConfig("mcp", {
55 url: "https://example.com/mcp",
56 queryParams: {
57 token: 123,
58 },
59 });
60
61 expect(result.isErr()).toBe(true);
62 if (result.isErr()) {
63 expect(result.error.message).toBe("Tool source queryParams.token must be a string");
64 }
65});
66
67test("normalizeToolSourceConfig validates auth mode for apiKey auth", () => {
68 const result = normalizeToolSourceConfig("openapi", {
69 spec: "https://example.com/openapi.json",
70 auth: {
71 type: "apiKey",
72 mode: "invalid",
73 header: "x-api-key",
74 },
75 });
76
77 expect(result.isErr()).toBe(true);
78 if (result.isErr()) {
79 expect(result.error.message).toBe("Tool source auth.mode must be 'static', 'workspace', or 'actor'");
80 }
81});
executor/packages/database/src/database/tool_source_config.ts+106−43
@@ -1,4 +1,5 @@
1import { Result } from "better-result";1import { Result } from "better-result";
2import { z } from "zod";
2import type {3import type {
3 GraphqlToolSourceConfig,4 GraphqlToolSourceConfig,
4 McpToolSourceConfig,5 McpToolSourceConfig,
@@ -30,6 +31,29 @@ export type NormalizedToolSourceConfig = · 25 unmodified lines
30 | NormalizedGraphqlToolSourceConfig31 | NormalizedGraphqlToolSourceConfig
31 | NormalizedOpenApiToolSourceConfig;32 | NormalizedOpenApiToolSourceConfig;
32 33
34const approvalModeSchema = z.enum(["auto", "required"]);
35const authModeSchema = z.enum(["static", "workspace", "actor"]);
36const mcpTransportSchema = z.enum(["sse", "streamable-http"]);
37const overrideEntrySchema = z.object({
38 approval: approvalModeSchema.optional(),
39});
40const unknownRecordSchema = z.record(z.unknown());
41const stringMapSchema = z.record(z.string());
42const basicAuthSchema = z.object({
43 mode: authModeSchema.optional(),
44 username: z.string().optional(),
45 password: z.string().optional(),
46});
47const bearerAuthSchema = z.object({
48 mode: authModeSchema.optional(),
49 token: z.string().optional(),
50});
51const apiKeyAuthSchema = z.object({
52 mode: authModeSchema.optional(),
53 header: z.string(),
54 value: z.string().optional(),
55});
56
33function optionalTrimmedString(value: unknown): string | undefined {57function optionalTrimmedString(value: unknown): string | undefined {
34 if (typeof value !== "string") return undefined;58 if (typeof value !== "string") return undefined;
35 const trimmed = value.trim();59 const trimmed = value.trim();
@@ -53,7 +77,17 @@ function normalizeStringMap( · 17 unmodified lines
53): Result<Record<string, string> | undefined, Error> {77): Result<Record<string, string> | undefined, Error> {
54 if (value === undefined) return Result.ok(undefined);78 if (value === undefined) return Result.ok(undefined);
55 79
56 const record = asRecord(value);80 const parsedRecord = stringMapSchema.safeParse(value);
81 if (!parsedRecord.success) {
82 const issue = parsedRecord.error.issues[0];
83 const keyPath = issue?.path[0];
84 if (typeof keyPath === "string") {
85 return Result.err(new Error(`Tool source ${fieldName}.${keyPath} must be a string`));
86 }
87 return Result.err(new Error(`Tool source ${fieldName} must be a map of strings`));
88 }
89
90 const record = parsedRecord.data;
57 if (Object.keys(record).length === 0) {91 if (Object.keys(record).length === 0) {
58 return Result.ok(undefined);92 return Result.ok(undefined);
59 }93 }
@@ -62,9 +96,6 @@ function normalizeStringMap( · 2 unmodified lines
62 for (const [key, rawValue] of Object.entries(record)) {96 for (const [key, rawValue] of Object.entries(record)) {
63 const normalizedKey = key.trim();97 const normalizedKey = key.trim();
64 if (!normalizedKey) continue;98 if (!normalizedKey) continue;
65 if (typeof rawValue !== "string") {
66 return Result.err(new Error(`Tool source ${fieldName}.${normalizedKey} must be a string`));
67 }
68 normalized[normalizedKey] = rawValue;99 normalized[normalizedKey] = rawValue;
69 }100 }
70 101
@@ -76,9 +107,12 @@ function normalizeApprovalMode( · 5 unmodified lines
76 fieldName: string,107 fieldName: string,
77): Result<ToolApprovalMode | undefined, Error> {108): Result<ToolApprovalMode | undefined, Error> {
78 if (value === undefined) return Result.ok(undefined);109 if (value === undefined) return Result.ok(undefined);
79 if (value === "auto" || value === "required") {110
80 return Result.ok(value);111 const parsed = approvalModeSchema.safeParse(value);
112 if (parsed.success) {
113 return Result.ok(parsed.data);
81 }114 }
115
82 return Result.err(new Error(`Tool source ${fieldName} must be 'auto' or 'required'`));116 return Result.err(new Error(`Tool source ${fieldName} must be 'auto' or 'required'`));
83}117}
84 118
@@ -88,7 +122,12 @@ function normalizeOverrides( · 3 unmodified lines
88): Result<Record<string, { approval?: ToolApprovalMode }> | undefined, Error> {122): Result<Record<string, { approval?: ToolApprovalMode }> | undefined, Error> {
89 if (value === undefined) return Result.ok(undefined);123 if (value === undefined) return Result.ok(undefined);
90 124
91 const raw = asRecord(value);125 const rawRecord = unknownRecordSchema.safeParse(value);
126 if (!rawRecord.success) {
127 return Result.err(new Error(`Tool source ${fieldName} must be an object`));
128 }
129
130 const raw = rawRecord.data;
92 if (Object.keys(raw).length === 0) {131 if (Object.keys(raw).length === 0) {
93 return Result.ok(undefined);132 return Result.ok(undefined);
94 }133 }
@@ -97,27 +136,18 @@ function normalizeOverrides( · 2 unmodified lines
97 for (const [rawKey, rawValue] of Object.entries(raw)) {136 for (const [rawKey, rawValue] of Object.entries(raw)) {
98 const key = rawKey.trim();137 const key = rawKey.trim();
99 if (!key) continue;138 if (!key) continue;
100 const entry = asRecord(rawValue);139
101 const approvalResult = normalizeApprovalMode(entry.approval, `${fieldName}.${key}.approval`);140 const parsedEntry = overrideEntrySchema.safeParse(rawValue);
102 if (approvalResult.isErr()) {141 if (!parsedEntry.success) {
103 return approvalResult;142 return Result.err(new Error(`Tool source ${fieldName}.${key}.approval must be 'auto' or 'required'`));
104 }143 }
105 normalized[key] = approvalResult.value ? { approval: approvalResult.value } : {};144
145 normalized[key] = parsedEntry.data.approval ? { approval: parsedEntry.data.approval } : {};
106 }146 }
107 147
108 return Result.ok(Object.keys(normalized).length > 0 ? normalized : undefined);148 return Result.ok(Object.keys(normalized).length > 0 ? normalized : undefined);
109}149}
110 150
111function normalizeAuthMode(
112 value: unknown,
113): Result<"static" | "workspace" | "actor" | undefined, Error> {
114 if (value === undefined) return Result.ok(undefined);
115 if (value === "static" || value === "workspace" || value === "actor") {
116 return Result.ok(value);
117 }
118 return Result.err(new Error("Tool source auth.mode must be 'static', 'workspace', or 'actor'"));
119}
120
121function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> {151function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> {
122 if (value === undefined) return Result.ok(undefined);152 if (value === undefined) return Result.ok(undefined);
123 153
@@ -131,39 +161,53 @@ function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> { · 7 unmodified lines
131 return Result.ok({ type: "none" });161 return Result.ok({ type: "none" });
132 }162 }
133 163
134 const modeResult = normalizeAuthMode(auth.mode);
135 if (modeResult.isErr()) {
136 return modeResult;
137 }
138 const mode = modeResult.value;
139
140 if (authType === "basic") {164 if (authType === "basic") {
165 const parsed = basicAuthSchema.safeParse(auth);
166 if (!parsed.success) {
167 return Result.err(new Error("Tool source auth.mode must be 'static', 'workspace', or 'actor'"));
168 }
169
141 return Result.ok({170 return Result.ok({
142 type: "basic",171 type: "basic",
143 mode,172 mode: parsed.data.mode,
144 username: optionalTrimmedString(auth.username),173 username: optionalTrimmedString(parsed.data.username),
145 password: optionalTrimmedString(auth.password),174 password: optionalTrimmedString(parsed.data.password),
146 });175 });
147 }176 }
148 177
149 if (authType === "bearer") {178 if (authType === "bearer") {
179 const parsed = bearerAuthSchema.safeParse(auth);
180 if (!parsed.success) {
181 return Result.err(new Error("Tool source auth.mode must be 'static', 'workspace', or 'actor'"));
182 }
183
150 return Result.ok({184 return Result.ok({
151 type: "bearer",185 type: "bearer",
152 mode,186 mode: parsed.data.mode,
153 token: optionalTrimmedString(auth.token),187 token: optionalTrimmedString(parsed.data.token),
154 });188 });
155 }189 }
156 190
157 if (authType === "apiKey") {191 if (authType === "apiKey") {
158 const headerResult = requiredTrimmedString(auth.header, "auth.header");192 const parsed = apiKeyAuthSchema.safeParse(auth);
193 if (!parsed.success) {
194 const headerIssue = parsed.error.issues.some((issue) => issue.path[0] === "header");
195 if (headerIssue) {
196 return Result.err(new Error("Tool source auth.header is required"));
197 }
198 return Result.err(new Error("Tool source auth.mode must be 'static', 'workspace', or 'actor'"));
199 }
200
201 const headerResult = requiredTrimmedString(parsed.data.header, "auth.header");
159 if (headerResult.isErr()) {202 if (headerResult.isErr()) {
160 return headerResult;203 return headerResult;
161 }204 }
205
162 return Result.ok({206 return Result.ok({
163 type: "apiKey",207 type: "apiKey",
164 mode,208 mode: parsed.data.mode,
165 header: headerResult.value,209 header: headerResult.value,
166 value: optionalTrimmedString(auth.value),210 value: optionalTrimmedString(parsed.data.value),
167 });211 });
168 }212 }
169 213
@@ -173,15 +217,21 @@ function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> { · 3 unmodified lines
173function normalizeSpec(217function normalizeSpec(
174 value: unknown,218 value: unknown,
175): Result<string | Record<string, unknown>, Error> {219): Result<string | Record<string, unknown>, Error> {
176 if (typeof value === "string") {220 const stringSpec = z.string().safeParse(value);
177 const trimmed = value.trim();221 if (stringSpec.success) {
222 const trimmed = stringSpec.data.trim();
178 if (!trimmed) {223 if (!trimmed) {
179 return Result.err(new Error("Tool source spec is required"));224 return Result.err(new Error("Tool source spec is required"));
180 }225 }
181 return Result.ok(trimmed);226 return Result.ok(trimmed);
182 }227 }
183 228
184 const specObject = asRecord(value);229 const specObjectResult = unknownRecordSchema.safeParse(value);
230 if (!specObjectResult.success) {
231 return Result.err(new Error("Tool source spec must be a non-empty string or object"));
232 }
233
234 const specObject = specObjectResult.data;
185 if (Object.keys(specObject).length === 0) {235 if (Object.keys(specObject).length === 0) {
186 return Result.err(new Error("Tool source spec must be a non-empty string or object"));236 return Result.err(new Error("Tool source spec must be a non-empty string or object"));
187 }237 }
@@ -209,7 +259,11 @@ export function normalizeToolSourceConfig( · 21 unmodified lines
209 type: ToolSourceType,259 type: ToolSourceType,
210 rawConfig: unknown,260 rawConfig: unknown,
211): Result<NormalizedToolSourceConfig, Error> {261): Result<NormalizedToolSourceConfig, Error> {
212 const config = asRecord(rawConfig);262 const configResult = unknownRecordSchema.safeParse(rawConfig);
263 if (!configResult.success) {
264 return Result.err(new Error("Tool source config must be an object"));
265 }
266 const config = configResult.data;
213 267
214 if (type === "mcp") {268 if (type === "mcp") {
215 const urlResult = requiredTrimmedString(config.url, "url");269 const urlResult = requiredTrimmedString(config.url, "url");
@@ -217,10 +271,18 @@ export function normalizeToolSourceConfig( · 1 unmodified line
217 return urlResult;271 return urlResult;
218 }272 }
219 273
220 const transport = config.transport;274 const transportResult = config.transport === undefined
221 if (transport !== undefined && transport !== "sse" && transport !== "streamable-http") {275 ? Result.ok(undefined)
276 : (() => {
277 const parsed = mcpTransportSchema.safeParse(config.transport);
278 return parsed.success
279 ? Result.ok(parsed.data)
280 : Result.err(new Error("Tool source transport must be 'sse' or 'streamable-http'"));
281 })();
282 if (transportResult.isErr()) {
222 return Result.err(new Error("Tool source transport must be 'sse' or 'streamable-http'"));283 return Result.err(new Error("Tool source transport must be 'sse' or 'streamable-http'"));
223 }284 }
285 const transport = transportResult.value;
224 286
225 const authResult = normalizeAuth(config.auth);287 const authResult = normalizeAuth(config.auth);
226 if (authResult.isErr()) {288 if (authResult.isErr()) {
@@ -290,7 +352,8 @@ export function normalizeToolSourceConfig( · 63 unmodified lines
290 return overridesResult;352 return overridesResult;
291 }353 }
292 354
293 const schema = asRecord(config.schema);355 const schemaResult = unknownRecordSchema.safeParse(config.schema);
356 const schema = schemaResult.success ? schemaResult.data : {};
294 357
295 return Result.ok({358 return Result.ok({
296 endpoint: endpointResult.value,359 endpoint: endpointResult.value,
executor/packages/database/src/runtime/tool_call_credentials.ts+34−8
@@ -1,6 +1,7 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";3import { Result } from "better-result";
4import { z } from "zod";
4import type { ActionCtx } from "../../convex/_generated/server";5import type { ActionCtx } from "../../convex/_generated/server";
5import { internal } from "../../convex/_generated/api";6import { internal } from "../../convex/_generated/api";
6import { resolveCredentialPayload } from "../../../core/src/credential-providers";7import { resolveCredentialPayload } from "../../../core/src/credential-providers";
@@ -8,6 +9,25 @@ import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredential · 1 unmodified line
8import { ToolCallControlError } from "../../../core/src/tool-call-control";9import { ToolCallControlError } from "../../../core/src/tool-call-control";
9import { asPayload } from "../lib/object";10import { asPayload } from "../lib/object";
10 11
12const bearerSecretSchema = z.object({
13 token: z.coerce.string().optional(),
14});
15
16const apiKeySecretSchema = z.object({
17 headerName: z.coerce.string().optional(),
18 value: z.coerce.string().optional(),
19 token: z.coerce.string().optional(),
20});
21
22const basicSecretSchema = z.object({
23 username: z.coerce.string().optional(),
24 password: z.coerce.string().optional(),
25});
26
27const credentialOverrideHeadersSchema = z.object({
28 headers: z.record(z.coerce.string()).optional(),
29});
30
11export async function resolveCredentialHeaders(31export async function resolveCredentialHeaders(
12 ctx: ActionCtx,32 ctx: ActionCtx,
13 spec: ToolCredentialSpec,33 spec: ToolCredentialSpec,
@@ -30,26 +50,32 @@ export async function resolveCredentialHeaders( · 16 unmodified lines
30 50
31 const headers: Record<string, string> = {};51 const headers: Record<string, string> = {};
32 if (spec.authType === "bearer") {52 if (spec.authType === "bearer") {
33 const token = String(sourcePayload.token ?? "").trim();53 const parsedSecret = bearerSecretSchema.safeParse(sourcePayload);
54 const token = parsedSecret.success ? (parsedSecret.data.token ?? "").trim() : "";
34 if (token) headers.authorization = `Bearer ${token}`;55 if (token) headers.authorization = `Bearer ${token}`;
35 } else if (spec.authType === "apiKey") {56 } else if (spec.authType === "apiKey") {
36 const headerName = spec.headerName ?? String(sourcePayload.headerName ?? "x-api-key");57 const parsedSecret = apiKeySecretSchema.safeParse(sourcePayload);
37 const value = String(sourcePayload.value ?? sourcePayload.token ?? "").trim();58 const headerName = spec.headerName
59 ?? (parsedSecret.success ? (parsedSecret.data.headerName ?? "x-api-key") : "x-api-key");
60 const value = parsedSecret.success
61 ? (parsedSecret.data.value ?? parsedSecret.data.token ?? "").trim()
62 : "";
38 if (value) headers[headerName] = value;63 if (value) headers[headerName] = value;
39 } else if (spec.authType === "basic") {64 } else if (spec.authType === "basic") {
40 const username = String(sourcePayload.username ?? "");65 const parsedSecret = basicSecretSchema.safeParse(sourcePayload);
41 const password = String(sourcePayload.password ?? "");66 const username = parsedSecret.success ? (parsedSecret.data.username ?? "") : "";
67 const password = parsedSecret.success ? (parsedSecret.data.password ?? "") : "";
42 if (username || password) {68 if (username || password) {
43 const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");69 const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
44 headers.authorization = `Basic ${encoded}`;70 headers.authorization = `Basic ${encoded}`;
45 }71 }
46 }72 }
47 73
48 const bindingOverrides = asPayload(record?.overridesJson ?? {});74 const bindingOverrides = credentialOverrideHeadersSchema.safeParse(record?.overridesJson ?? {});
49 const overrideHeaders = asPayload(bindingOverrides.headers);75 const overrideHeaders = bindingOverrides.success ? (bindingOverrides.data.headers ?? {}) : {};
50 for (const [key, value] of Object.entries(overrideHeaders)) {76 for (const [key, value] of Object.entries(overrideHeaders)) {
51 if (!key) continue;77 if (!key) continue;
52 headers[key] = String(value);78 headers[key] = value;
53 }79 }
54 80
55 if (Object.keys(headers).length === 0) {81 if (Object.keys(headers).length === 0) {
executor/packages/database/src/runtime/tool_call_resolution.ts+29−11
@@ -1,11 +1,12 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";3import { Result } from "better-result";
4import { z } from "zod";
4import type { ActionCtx } from "../../convex/_generated/server";5import type { ActionCtx } from "../../convex/_generated/server";
5import { internal } from "../../convex/_generated/api";6import { internal } from "../../convex/_generated/api";
6import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths";7import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths";
7import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";8import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
8import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization";9import { parseSerializedTool, rehydrateTools } from "../../../core/src/tool/source-serialization";
9import { asPayload } from "../lib/object";10import { asPayload } from "../lib/object";
10import { getDecisionForContext, getToolDecision } from "./policy";11import { getDecisionForContext, getToolDecision } from "./policy";
11import { getReadyRegistryBuildId } from "./tool_registry_state";12import { getReadyRegistryBuildId } from "./tool_registry_state";
@@ -17,20 +18,31 @@ type RegistrySerializedToolEntry = { · 5 unmodified lines
17 serializedToolJson: string;18 serializedToolJson: string;
18};19};
19 20
21const registrySearchEntrySchema = z.object({
22 preferredPath: z.string(),
23});
24
25const registrySerializedToolEntrySchema = z.object({
26 path: z.string(),
27 serializedToolJson: z.string(),
28});
29
20async function searchRegistryEntries(30async function searchRegistryEntries(
21 ctx: ActionCtx,31 ctx: ActionCtx,
22 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number },32 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number },
23): Promise<Array<{ preferredPath: string }>> {33): Promise<Array<{ preferredPath: string }>> {
24 const entries: Array<{ preferredPath: string }> = await ctx.runQuery(internal.toolRegistry.searchTools, args);34 const entries = await ctx.runQuery(internal.toolRegistry.searchTools, args);
25 return entries;35 const parsed = z.array(registrySearchEntrySchema).safeParse(entries);
36 return parsed.success ? parsed.data : [];
26}37}
27 38
28async function getRegistryToolByPath(39async function getRegistryToolByPath(
29 ctx: ActionCtx,40 ctx: ActionCtx,
30 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; path: string },41 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; path: string },
31): Promise<RegistrySerializedToolEntry | null> {42): Promise<RegistrySerializedToolEntry | null> {
32 const entry: RegistrySerializedToolEntry | null = await ctx.runQuery(internal.toolRegistry.getToolByPath, args);43 const entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, args);
33 return entry;44 const parsed = registrySerializedToolEntrySchema.safeParse(entry);
45 return parsed.success ? parsed.data : null;
34}46}
35 47
36async function getRegistryToolsByNormalizedPath(48async function getRegistryToolsByNormalizedPath(
@@ -42,8 +54,9 @@ async function getRegistryToolsByNormalizedPath( · 5 unmodified lines
42 limit: number;54 limit: number;
43 },55 },
44): Promise<RegistrySerializedToolEntry[]> {56): Promise<RegistrySerializedToolEntry[]> {
45 const entries: RegistrySerializedToolEntry[] = await ctx.runQuery(internal.toolRegistry.getToolsByNormalizedPath, args);57 const entries = await ctx.runQuery(internal.toolRegistry.getToolsByNormalizedPath, args);
46 return entries;58 const parsed = z.array(registrySerializedToolEntrySchema).safeParse(entries);
59 return parsed.success ? parsed.data : [];
47}60}
48 61
49export function getGraphqlDecision(62export function getGraphqlDecision(
@@ -177,16 +190,21 @@ export async function resolveToolForCall( · 127 unmodified lines
177 }190 }
178 191
179 const serializedResult = Result.try(() => {192 const serializedResult = Result.try(() => {
180 const serialized: SerializedTool = JSON.parse(entry.serializedToolJson);193 return JSON.parse(entry.serializedToolJson);
181 return serialized;
182 });194 });
183 if (serializedResult.isErr()) {195 if (serializedResult.isErr()) {
184 return Result.err(196 return Result.err(
185 new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serializedResult.error.message}`),197 new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serializedResult.error.message}`),
186 );198 );
187 }199 }
188 const serialized = serializedResult.value;200 const serialized = parseSerializedTool(serializedResult.value);
189 const [tool] = rehydrateTools([serialized], baseTools);201 if (serialized.isErr()) {
202 return Result.err(
203 new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serialized.error.message}`),
204 );
205 }
206
207 const [tool] = rehydrateTools([serialized.value], baseTools);
190 if (!tool) {208 if (!tool) {
191 return Result.err(new Error(`Failed to rehydrate tool: ${resolvedToolPath}`));209 return Result.err(new Error(`Failed to rehydrate tool: ${resolvedToolPath}`));
192 }210 }
executor/packages/database/src/runtime/tool_invocation.ts+70−19
@@ -1,5 +1,6 @@
1"use node";1"use node";
2 2
3import { z } from "zod";
3import type { ActionCtx } from "../../convex/_generated/server";4import type { ActionCtx } from "../../convex/_generated/server";
4import { internal } from "../../convex/_generated/api";5import { internal } from "../../convex/_generated/api";
5import type {6import type {
@@ -40,6 +41,50 @@ type RegistryToolEntry = { · 34 unmodified lines
40 displayOutput?: string;41 displayOutput?: string;
41};42};
42 43
44const accessPolicyRecordSchema = z.object({
45 id: z.string(),
46 workspaceId: z.custom<TaskRecord["workspaceId"]>(),
47 actorId: z.string().optional(),
48 clientId: z.string().optional(),
49 toolPathPattern: z.string(),
50 decision: z.enum(["allow", "require_approval", "deny"]),
51 priority: z.number(),
52 createdAt: z.number(),
53 updatedAt: z.number(),
54});
55
56const registryNamespaceSchema = z.object({
57 namespace: z.string(),
58 toolCount: z.number(),
59 samplePaths: z.array(z.string()),
60});
61
62const registryToolEntrySchema: z.ZodType<RegistryToolEntry> = z.object({
63 path: z.string(),
64 preferredPath: z.string().optional(),
65 source: z.string().optional(),
66 approval: z.enum(["auto", "required"]),
67 description: z.string().optional(),
68 displayInput: z.string().optional(),
69 displayOutput: z.string().optional(),
70});
71
72const catalogNamespacesInputSchema = z.object({
73 limit: z.coerce.number().optional(),
74});
75
76const catalogToolsInputSchema = z.object({
77 namespace: z.string().optional(),
78 query: z.string().optional(),
79 limit: z.coerce.number().optional(),
80});
81
82const discoverInputSchema = z.object({
83 query: z.string().optional(),
84 limit: z.coerce.number().optional(),
85 compact: z.boolean().optional(),
86});
87
43async function upsertRequestedToolCall(88async function upsertRequestedToolCall(
44 ctx: ActionCtx,89 ctx: ActionCtx,
45 args: { taskId: string; callId: string; workspaceId: TaskRecord["workspaceId"]; toolPath: string },90 args: { taskId: string; callId: string; workspaceId: TaskRecord["workspaceId"]; toolPath: string },
@@ -52,36 +97,36 @@ async function listWorkspaceAccessPolicies( · 6 unmodified lines
52 ctx: ActionCtx,97 ctx: ActionCtx,
53 workspaceId: TaskRecord["workspaceId"],98 workspaceId: TaskRecord["workspaceId"],
54): Promise<AccessPolicyRecord[]> {99): Promise<AccessPolicyRecord[]> {
55 const policies: AccessPolicyRecord[] = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId });100 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId });
56 return policies;101 const parsed = z.array(accessPolicyRecordSchema).safeParse(policies);
102 return parsed.success ? parsed.data : [];
57}103}
58 104
59async function listRegistryNamespaces(105async function listRegistryNamespaces(
60 ctx: ActionCtx,106 ctx: ActionCtx,
61 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; limit: number },107 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; limit: number },
62): Promise<Array<{ namespace: string; toolCount: number; samplePaths: string[] }>> {108): Promise<Array<{ namespace: string; toolCount: number; samplePaths: string[] }>> {
63 const namespaces: Array<{109 const namespaces = await ctx.runQuery(internal.toolRegistry.listNamespaces, args);
64 namespace: string;110 const parsed = z.array(registryNamespaceSchema).safeParse(namespaces);
65 toolCount: number;111 return parsed.success ? parsed.data : [];
66 samplePaths: string[];
67 }> = await ctx.runQuery(internal.toolRegistry.listNamespaces, args);
68 return namespaces;
69}112}
70 113
71async function searchRegistryTools(114async function searchRegistryTools(
72 ctx: ActionCtx,115 ctx: ActionCtx,
73 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number },116 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number },
74): Promise<RegistryToolEntry[]> {117): Promise<RegistryToolEntry[]> {
75 const entries: RegistryToolEntry[] = await ctx.runQuery(internal.toolRegistry.searchTools, args);118 const entries = await ctx.runQuery(internal.toolRegistry.searchTools, args);
76 return entries;119 const parsed = z.array(registryToolEntrySchema).safeParse(entries);
120 return parsed.success ? parsed.data : [];
77}121}
78 122
79async function listRegistryToolsByNamespace(123async function listRegistryToolsByNamespace(
80 ctx: ActionCtx,124 ctx: ActionCtx,
81 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; namespace: string; limit: number },125 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; namespace: string; limit: number },
82): Promise<RegistryToolEntry[]> {126): Promise<RegistryToolEntry[]> {
83 const entries: RegistryToolEntry[] = await ctx.runQuery(internal.toolRegistry.listToolsByNamespace, args);127 const entries = await ctx.runQuery(internal.toolRegistry.listToolsByNamespace, args);
84 return entries;128 const parsed = z.array(registryToolEntrySchema).safeParse(entries);
129 return parsed.success ? parsed.data : [];
85}130}
86 131
87async function denyToolCallForApproval(132async function denyToolCallForApproval(
@@ -168,7 +213,9 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 80 unmodified lines
168 };213 };
169 214
170 if (toolPath === "catalog.namespaces") {215 if (toolPath === "catalog.namespaces") {
171 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200)));216 const parsedInput = catalogNamespacesInputSchema.safeParse(payload);
217 const limitInput = parsedInput.success ? parsedInput.data.limit : undefined;
218 const limit = Math.max(1, Math.min(200, Number(limitInput ?? 200)));
172 const namespaces = await listRegistryNamespaces(ctx, {219 const namespaces = await listRegistryNamespaces(ctx, {
173 workspaceId: task.workspaceId,220 workspaceId: task.workspaceId,
174 buildId,221 buildId,
@@ -178,9 +225,11 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 3 unmodified lines
178 }225 }
179 226
180 if (toolPath === "catalog.tools") {227 if (toolPath === "catalog.tools") {
181 const namespace = String(payload.namespace ?? "").trim().toLowerCase();228 const parsedInput = catalogToolsInputSchema.safeParse(payload);
182 const query = String(payload.query ?? "").trim();229 const namespace = (parsedInput.success ? (parsedInput.data.namespace ?? "") : "").trim().toLowerCase();
183 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));230 const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim();
231 const limitInput = parsedInput.success ? parsedInput.data.limit : undefined;
232 const limit = Math.max(1, Math.min(200, Number(limitInput ?? 50)));
184 233
185 const raw = query234 const raw = query
186 ? await searchRegistryTools(ctx, {235 ? await searchRegistryTools(ctx, {
@@ -219,9 +268,11 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 32 unmodified lines
219 }268 }
220 269
221 // discover270 // discover
222 const query = String(payload.query ?? "").trim();271 const parsedInput = discoverInputSchema.safeParse(payload);
223 const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));272 const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim();
224 const compact = payload.compact === false ? false : true;273 const limitInput = parsedInput.success ? parsedInput.data.limit : undefined;
274 const limit = Math.max(1, Math.min(50, Number(limitInput ?? 8)));
275 const compact = parsedInput.success ? (parsedInput.data.compact ?? true) : true;
225 const hits = await searchRegistryTools(ctx, {276 const hits = await searchRegistryTools(ctx, {
226 workspaceId: task.workspaceId,277 workspaceId: task.workspaceId,
227 buildId,278 buildId,
executor/packages/database/src/runtime/tool_registry_state.ts+22−15
@@ -1,9 +1,9 @@
1"use node";1"use node";
2 2
3import { z } from "zod";
3import type { ActionCtx } from "../../convex/_generated/server";4import type { ActionCtx } from "../../convex/_generated/server";
4import type { Id } from "../../convex/_generated/dataModel.d.ts";5import type { Id } from "../../convex/_generated/dataModel.d.ts";
5import { internal } from "../../convex/_generated/api";6import { internal } from "../../convex/_generated/api";
6import { asPayload } from "../lib/object";
7import { sourceSignature } from "./tool_source_loading";7import { sourceSignature } from "./tool_source_loading";
8 8
9export const TOOL_REGISTRY_SIGNATURE_PREFIX = "toolreg_v2|";9export const TOOL_REGISTRY_SIGNATURE_PREFIX = "toolreg_v2|";
@@ -27,19 +27,26 @@ type ToolSourceState = { · 17 unmodified lines
27 enabled: boolean;27 enabled: boolean;
28};28};
29 29
30function toRegistryState(value: unknown): RegistryState {30const registryStateSchema = z.object({
31 if (!value || typeof value !== "object" || Array.isArray(value)) {31 signature: z.string(),
32 return null;32 readyBuildId: z.string().optional(),
33 }33});
34 34
35 const state = asPayload(value);35const toolSourceStateSchema = z.object({
36 if (typeof state.signature !== "string") {36 id: z.string(),
37 updatedAt: z.number(),
38 enabled: z.boolean().optional(),
39});
40
41function toRegistryState(value: unknown): RegistryState {
42 const parsed = registryStateSchema.safeParse(value);
43 if (!parsed.success) {
37 return null;44 return null;
38 }45 }
39 46
40 return {47 return {
41 signature: state.signature,48 signature: parsed.data.signature,
42 readyBuildId: typeof state.readyBuildId === "string" ? state.readyBuildId : undefined,49 readyBuildId: parsed.data.readyBuildId,
43 };50 };
44}51}
45 52
@@ -50,13 +57,13 @@ function toToolSourceStateList(value: unknown): ToolSourceState[] { · 4 unmodified lines
50 57
51 const normalized: ToolSourceState[] = [];58 const normalized: ToolSourceState[] = [];
52 for (const entry of value) {59 for (const entry of value) {
53 const record = asPayload(entry);60 const parsed = toolSourceStateSchema.safeParse(entry);
54 if (typeof record.id !== "string") continue;61 if (!parsed.success) continue;
55 if (typeof record.updatedAt !== "number") continue;62
56 normalized.push({63 normalized.push({
57 id: record.id,64 id: parsed.data.id,
58 updatedAt: record.updatedAt,65 updatedAt: parsed.data.updatedAt,
59 enabled: record.enabled !== false,66 enabled: parsed.data.enabled !== false,
60 });67 });
61 }68 }
62 69
executor/packages/database/src/runtime/tool_source_loading.ts+96−42
@@ -1,6 +1,7 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";3import { Result } from "better-result";
4import { z } from "zod";
4import type { ActionCtx } from "../../convex/_generated/server";5import type { ActionCtx } from "../../convex/_generated/server";
5import { internal } from "../../convex/_generated/api";6import { internal } from "../../convex/_generated/api";
6import type { Id } from "../../convex/_generated/dataModel.d.ts";7import type { Id } from "../../convex/_generated/dataModel.d.ts";
@@ -22,37 +23,79 @@ import type { · 15 unmodified lines
22} from "../../../core/src/tool/source-types";23} from "../../../core/src/tool/source-types";
23import type { ToolSourceRecord } from "../../../core/src/types";24import type { ToolSourceRecord } from "../../../core/src/types";
24import { normalizeToolSourceConfig } from "../database/tool_source_config";25import { normalizeToolSourceConfig } from "../database/tool_source_config";
25import { asPayload } from "../lib/object";
26 26
27const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;27const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
28 28
29/** Cache version - bump when tool snapshot/registry/type-hint semantics change. */29/** Cache version - bump when tool snapshot/registry/type-hint semantics change. */
30const TOOL_SOURCE_CACHE_VERSION = "v25";30const TOOL_SOURCE_CACHE_VERSION = "v25";
31 31
32const openApiAuthModeSchema = z.enum(["static", "workspace", "actor"]);
33
34const openApiAuthSchema = z.union([
35 z.object({ type: z.literal("none") }),
36 z.object({
37 type: z.literal("basic"),
38 mode: openApiAuthModeSchema.optional(),
39 username: z.string().optional(),
40 password: z.string().optional(),
41 }),
42 z.object({
43 type: z.literal("bearer"),
44 mode: openApiAuthModeSchema.optional(),
45 token: z.string().optional(),
46 }),
47 z.object({
48 type: z.literal("apiKey"),
49 mode: openApiAuthModeSchema.optional(),
50 header: z.string(),
51 value: z.string().optional(),
52 }),
53]);
54
55const bearerSecretSchema = z.object({ token: z.coerce.string().optional() });
56const apiKeySecretSchema = z.object({
57 value: z.coerce.string().optional(),
58 token: z.coerce.string().optional(),
59});
60const basicSecretSchema = z.object({
61 username: z.coerce.string().optional(),
62 password: z.coerce.string().optional(),
63});
64const credentialOverrideHeadersSchema = z.object({
65 headers: z.record(z.coerce.string()).optional(),
66});
67
68const preparedOpenApiSpecSchema = z.object({
69 servers: z.array(z.string()),
70 paths: z.record(z.unknown()),
71 warnings: z.array(z.string()).optional(),
72 dts: z.string().optional(),
73 dtsStatus: z.enum(["ready", "failed", "skipped"]).optional(),
74 inferredAuth: openApiAuthSchema.optional(),
75});
76
77const rawToolSourceSchema = z.object({
78 id: z.string(),
79 type: z.enum(["mcp", "openapi", "graphql"]),
80 name: z.string(),
81 config: z.record(z.unknown()),
82});
83
32function toPreparedOpenApiSpec(value: unknown): PreparedOpenApiSpec | null {84function toPreparedOpenApiSpec(value: unknown): PreparedOpenApiSpec | null {
33 const record = asPayload(value);85 const parsed = preparedOpenApiSpecSchema.safeParse(value);
34 if (!Array.isArray(record.servers)) {86 if (!parsed.success) {
35 return null;
36 }
37 if (!record.paths || typeof record.paths !== "object" || Array.isArray(record.paths)) {
38 return null;87 return null;
39 }88 }
40 89
41 const servers = record.servers.filter((entry): entry is string => typeof entry === "string");90 const valueRecord = parsed.data;
42 const warnings = Array.isArray(record.warnings)
43 ? record.warnings.filter((entry): entry is string => typeof entry === "string")
44 : [];
45 const dtsStatus =
46 record.dtsStatus === "ready" || record.dtsStatus === "failed" || record.dtsStatus === "skipped"
47 ? record.dtsStatus
48 : undefined;
49 91
50 return {92 return {
51 servers,93 servers: valueRecord.servers,
52 paths: asPayload(record.paths),94 paths: valueRecord.paths,
53 warnings,95 warnings: valueRecord.warnings ?? [],
54 dts: typeof record.dts === "string" ? record.dts : undefined,96 dts: valueRecord.dts,
55 dtsStatus,97 dtsStatus: valueRecord.dtsStatus,
98 inferredAuth: valueRecord.inferredAuth,
56 };99 };
57}100}
58 101
@@ -69,20 +112,25 @@ export function normalizeExternalToolSource(raw: { · 10 unmodified lines
69 name: string;112 name: string;
70 config: Record<string, unknown>;113 config: Record<string, unknown>;
71}): Result<ExternalToolSourceConfig, Error> {114}): Result<ExternalToolSourceConfig, Error> {
115 const parsedRaw = rawToolSourceSchema.safeParse(raw);
116 if (!parsedRaw.success) {
117 return Result.err(new Error(`Failed to normalize tool source: ${parsedRaw.error.message}`));
118 }
119 const normalizedRaw = parsedRaw.data;
72 120
73 if (raw.type === "mcp") {121 if (normalizedRaw.type === "mcp") {
74 const configResult = normalizeToolSourceConfig("mcp", raw.config);122 const configResult = normalizeToolSourceConfig("mcp", normalizedRaw.config);
75 if (configResult.isErr()) {123 if (configResult.isErr()) {
76 return Result.err(124 return Result.err(
77 new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`),125 new Error(`Failed to normalize '${normalizedRaw.name}' source config: ${configResult.error.message}`),
78 );126 );
79 }127 }
80 const config = configResult.value;128 const config = configResult.value;
81 const result: McpToolSourceConfig = {129 const result: McpToolSourceConfig = {
82 type: "mcp",130 type: "mcp",
83 name: raw.name,131 name: normalizedRaw.name,
84 sourceId: raw.id,132 sourceId: normalizedRaw.id,
85 sourceKey: `source:${raw.id}`,133 sourceKey: `source:${normalizedRaw.id}`,
86 url: config.url,134 url: config.url,
87 auth: config.auth,135 auth: config.auth,
88 transport: config.transport,136 transport: config.transport,
@@ -93,19 +141,19 @@ export function normalizeExternalToolSource(raw: { · 4 unmodified lines
93 return Result.ok(result);141 return Result.ok(result);
94 }142 }
95 143
96 if (raw.type === "graphql") {144 if (normalizedRaw.type === "graphql") {
97 const configResult = normalizeToolSourceConfig("graphql", raw.config);145 const configResult = normalizeToolSourceConfig("graphql", normalizedRaw.config);
98 if (configResult.isErr()) {146 if (configResult.isErr()) {
99 return Result.err(147 return Result.err(
100 new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`),148 new Error(`Failed to normalize '${normalizedRaw.name}' source config: ${configResult.error.message}`),
101 );149 );
102 }150 }
103 const config = configResult.value;151 const config = configResult.value;
104 const result: GraphqlToolSourceConfig = {152 const result: GraphqlToolSourceConfig = {
105 type: "graphql",153 type: "graphql",
106 name: raw.name,154 name: normalizedRaw.name,
107 sourceId: raw.id,155 sourceId: normalizedRaw.id,
108 sourceKey: `source:${raw.id}`,156 sourceKey: `source:${normalizedRaw.id}`,
109 endpoint: config.endpoint,157 endpoint: config.endpoint,
110 schema: config.schema,158 schema: config.schema,
111 auth: config.auth,159 auth: config.auth,
@@ -116,19 +164,19 @@ export function normalizeExternalToolSource(raw: { · 4 unmodified lines
116 return Result.ok(result);164 return Result.ok(result);
117 }165 }
118 166
119 const configResult = normalizeToolSourceConfig("openapi", raw.config);167 const configResult = normalizeToolSourceConfig("openapi", normalizedRaw.config);
120 if (configResult.isErr()) {168 if (configResult.isErr()) {
121 return Result.err(169 return Result.err(
122 new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`),170 new Error(`Failed to normalize '${normalizedRaw.name}' source config: ${configResult.error.message}`),
123 );171 );
124 }172 }
125 const config = configResult.value;173 const config = configResult.value;
126 174
127 const result: OpenApiToolSourceConfig = {175 const result: OpenApiToolSourceConfig = {
128 type: "openapi",176 type: "openapi",
129 name: raw.name,177 name: normalizedRaw.name,
130 sourceId: raw.id,178 sourceId: normalizedRaw.id,
131 sourceKey: `source:${raw.id}`,179 sourceKey: `source:${normalizedRaw.id}`,
132 spec: config.spec,180 spec: config.spec,
133 collectionUrl: config.collectionUrl,181 collectionUrl: config.collectionUrl,
134 postmanProxyUrl: config.postmanProxyUrl,182 postmanProxyUrl: config.postmanProxyUrl,
@@ -146,18 +194,23 @@ function buildHeadersFromCredentialSecret( · 11 unmodified lines
146 secret: Record<string, unknown>,194 secret: Record<string, unknown>,
147): Record<string, string> {195): Record<string, string> {
148 if (auth.type === "bearer") {196 if (auth.type === "bearer") {
149 const token = String(secret.token ?? "").trim();197 const parsedSecret = bearerSecretSchema.safeParse(secret);
198 const token = parsedSecret.success ? (parsedSecret.data.token ?? "").trim() : "";
150 return token ? { authorization: `Bearer ${token}` } : {};199 return token ? { authorization: `Bearer ${token}` } : {};
151 }200 }
152 201
153 if (auth.type === "apiKey") {202 if (auth.type === "apiKey") {
154 const value = String(secret.value ?? secret.token ?? "").trim();203 const parsedSecret = apiKeySecretSchema.safeParse(secret);
204 const value = parsedSecret.success
205 ? (parsedSecret.data.value ?? parsedSecret.data.token ?? "").trim()
206 : "";
155 return value ? { [auth.header]: value } : {};207 return value ? { [auth.header]: value } : {};
156 }208 }
157 209
158 if (auth.type === "basic") {210 if (auth.type === "basic") {
159 const username = String(secret.username ?? "");211 const parsedSecret = basicSecretSchema.safeParse(secret);
160 const password = String(secret.password ?? "");212 const username = parsedSecret.success ? (parsedSecret.data.username ?? "") : "";
213 const password = parsedSecret.success ? (parsedSecret.data.password ?? "") : "";
161 if (!username && !password) {214 if (!username && !password) {
162 return {};215 return {};
163 }216 }
@@ -215,10 +268,11 @@ async function resolveMcpDiscoveryHeaders( · 51 unmodified lines
215 }268 }
216 269
217 const headers = buildHeadersFromCredentialSecret(auth, secret);270 const headers = buildHeadersFromCredentialSecret(auth, secret);
218 const overrideHeaders = asPayload(asPayload(record.overridesJson).headers);271 const parsedOverrides = credentialOverrideHeadersSchema.safeParse(record.overridesJson ?? {});
272 const overrideHeaders = parsedOverrides.success ? (parsedOverrides.data.headers ?? {}) : {};
219 for (const [key, value] of Object.entries(overrideHeaders)) {273 for (const [key, value] of Object.entries(overrideHeaders)) {
220 if (!key) continue;274 if (!key) continue;
221 headers[key] = String(value);275 headers[key] = value;
222 }276 }
223 277
224 if (Object.keys(headers).length === 0) {278 if (Object.keys(headers).length === 0) {
executor/packages/database/src/runtime/workspace_tools.ts+82−38
@@ -1,5 +1,7 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";
4import { z } from "zod";
3import type { ActionCtx } from "../../convex/_generated/server";5import type { ActionCtx } from "../../convex/_generated/server";
4import { internal } from "../../convex/_generated/api";6import { internal } from "../../convex/_generated/api";
5import type { Id } from "../../convex/_generated/dataModel.d.ts";7import type { Id } from "../../convex/_generated/dataModel.d.ts";
@@ -8,6 +10,7 @@ import { jsonSchemaTypeHintFallback } from "../../../core/src/openapi/schema-hin · 2 unmodified lines
8import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../../core/src/tool-typing/schema-utils";10import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../../core/src/tool-typing/schema-utils";
9import {11import {
10 materializeCompiledToolSource,12 materializeCompiledToolSource,
13 parseWorkspaceToolSnapshot,
11 materializeWorkspaceSnapshot,14 materializeWorkspaceSnapshot,
12 type CompiledToolSourceArtifact,15 type CompiledToolSourceArtifact,
13 type WorkspaceToolSnapshot,16 type WorkspaceToolSnapshot,
@@ -21,6 +24,7 @@ import type { · 7 unmodified lines
21 SourceAuthProfile,24 SourceAuthProfile,
22 ToolDefinition,25 ToolDefinition,
23 ToolDescriptor,26 ToolDescriptor,
27 ToolSourceRecord,
24} from "../../../core/src/types";28} from "../../../core/src/types";
25import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";29import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";
26import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";30import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
@@ -30,6 +34,41 @@ import { asPayload } from "../lib/object"; · 3 unmodified lines
30 34
31const baseTools = new Map<string, ToolDefinition>();35const baseTools = new Map<string, ToolDefinition>();
32 36
37const adminAnnouncementInputSchema = z.object({
38 channel: z.string().optional(),
39 message: z.string().optional(),
40});
41
42async function listWorkspaceToolSources(
43 ctx: ActionCtx,
44 workspaceId: Id<"workspaces">,
45): Promise<ToolSourceRecord[]> {
46 const sources: ToolSourceRecord[] = await ctx.runQuery(internal.database.listToolSources, { workspaceId });
47 return sources;
48}
49
50async function listWorkspaceAccessPolicies(
51 ctx: ActionCtx,
52 workspaceId: Id<"workspaces">,
53): Promise<AccessPolicyRecord[]> {
54 const policies: AccessPolicyRecord[] = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId });
55 return policies;
56}
57
58async function parseWorkspaceToolSnapshotFromBlob(blob: Blob): Promise<Result<WorkspaceToolSnapshot, Error>> {
59 const textResult = await Result.tryPromise(async () => await blob.text());
60 if (textResult.isErr()) {
61 return Result.err(new Error(`Failed to read cache blob: ${textResult.error.message}`));
62 }
63
64 const jsonResult = Result.try(() => JSON.parse(textResult.value));
65 if (jsonResult.isErr()) {
66 return Result.err(new Error(`Failed to parse cache JSON: ${jsonResult.error.message}`));
67 }
68
69 return parseWorkspaceToolSnapshot(jsonResult.value);
70}
71
33// Minimal built-in tools used by tests/demos.72// Minimal built-in tools used by tests/demos.
34// These are intentionally simple and are always approval-gated.73// These are intentionally simple and are always approval-gated.
35baseTools.set("admin.send_announcement", {74baseTools.set("admin.send_announcement", {
@@ -59,9 +98,9 @@ baseTools.set("admin.send_announcement", { · 23 unmodified lines
59 },98 },
60 },99 },
61 run: async (input: unknown) => {100 run: async (input: unknown) => {
62 const payload = asPayload(input);101 const parsedInput = adminAnnouncementInputSchema.safeParse(asPayload(input));
63 const channel = typeof payload.channel === "string" ? payload.channel : "";102 const channel = parsedInput.success ? (parsedInput.data.channel ?? "") : "";
64 const message = typeof payload.message === "string" ? payload.message : "";103 const message = parsedInput.success ? (parsedInput.data.message ?? "") : "";
65 return { ok: true, channel, message };104 return { ok: true, channel, message };
66 },105 },
67});106});
@@ -508,9 +547,9 @@ export async function getWorkspaceTools( · 440 unmodified lines
508 const sourceTimeoutMs = options.sourceTimeoutMs;547 const sourceTimeoutMs = options.sourceTimeoutMs;
509 const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false;548 const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false;
510 const actorId = options.actorId;549 const actorId = options.actorId;
511 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))550 const sources = (await listWorkspaceToolSources(ctx, workspaceId))
512 .filter((source: { enabled: boolean }) => source.enabled);551 .filter((source) => source.enabled);
513 const hasActorScopedMcpSource = sources.some((source: { type: string; config: Record<string, unknown> }) => {552 const hasActorScopedMcpSource = sources.some((source) => {
514 if (source.type !== "mcp") {553 if (source.type !== "mcp") {
515 return false;554 return false;
516 }555 }
@@ -520,7 +559,7 @@ export async function getWorkspaceTools( · 3 unmodified lines
520 const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource;559 const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource;
521 const skipCacheWrite = hasActorScopedMcpSource;560 const skipCacheWrite = hasActorScopedMcpSource;
522 traceStep("listToolSources", listSourcesStartedAt);561 traceStep("listToolSources", listSourcesStartedAt);
523 const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi");562 const hasOpenApiSource = sources.some((source) => source.type === "openapi");
524 const signature = sourceSignature(workspaceId, sources);563 const signature = sourceSignature(workspaceId, sources);
525 const registrySignature = registrySignatureForWorkspace(workspaceId, sources);564 const registrySignature = registrySignatureForWorkspace(workspaceId, sources);
526 const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = {565 const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = {
@@ -543,47 +582,53 @@ export async function getWorkspaceTools( · 16 unmodified lines
543 const cacheHydrateStartedAt = Date.now();582 const cacheHydrateStartedAt = Date.now();
544 const blob = await ctx.storage.get(cacheEntry.storageId);583 const blob = await ctx.storage.get(cacheEntry.storageId);
545 if (blob) {584 if (blob) {
546 const snapshot: WorkspaceToolSnapshot = JSON.parse(await blob.text());585 const parsedSnapshot = await parseWorkspaceToolSnapshotFromBlob(blob);
547 const restored = materializeWorkspaceSnapshot(snapshot);586 if (parsedSnapshot.isErr()) {
548 const merged = mergeTools(restored);587 trace.push("cacheHydrate=invalidSnapshot");
549 traceStep("cacheHydrate", cacheHydrateStartedAt);588 console.warn(`[executor] invalid workspace tool cache snapshot for '${workspaceId}': ${parsedSnapshot.error.message}`);
550 589 } else {
551 const typesStorageId: Id<"_storage"> | undefined = cacheEntry.typesStorageId;590 const snapshot = parsedSnapshot.value;
552 if (cacheEntry.isFresh) {591 const restored = materializeWorkspaceSnapshot(snapshot);
553 if (typesStorageId) {592 const merged = mergeTools(restored);
593 traceStep("cacheHydrate", cacheHydrateStartedAt);
594
595 const typesStorageId: Id<"_storage"> | undefined = cacheEntry.typesStorageId;
596 if (cacheEntry.isFresh) {
597 if (typesStorageId) {
598 return {
599 tools: merged,
600 warnings: snapshot.warnings,
601 typesStorageId,
602 debug: {
603 ...debugBase,
604 mode: "cache-fresh",
605 normalizedSourceCount: sources.length,
606 cacheHit: true,
607 cacheFresh: true,
608 timedOutSources: [],
609 durationMs: Date.now() - startedAt,
610 trace,
611 },
612 };
613 }
614 // Continue into rebuild path to generate missing type bundle.
615 } else if (allowStaleOnMismatch) {
554 return {616 return {
555 tools: merged,617 tools: merged,
556 warnings: snapshot.warnings,618 warnings: [...snapshot.warnings, "Tool sources changed; showing previous results while refreshing."],
557 typesStorageId,619 typesStorageId,
558 debug: {620 debug: {
559 ...debugBase,621 ...debugBase,
560 mode: "cache-fresh",622 mode: "cache-stale",
561 normalizedSourceCount: sources.length,623 normalizedSourceCount: sources.length,
562 cacheHit: true,624 cacheHit: true,
563 cacheFresh: true,625 cacheFresh: false,
564 timedOutSources: [],626 timedOutSources: [],
565 durationMs: Date.now() - startedAt,627 durationMs: Date.now() - startedAt,
566 trace,628 trace,
567 },629 },
568 };630 };
569 }631 }
570 // Continue into rebuild path to generate missing type bundle.
571 } else if (allowStaleOnMismatch) {
572 return {
573 tools: merged,
574 warnings: [...snapshot.warnings, "Tool sources changed; showing previous results while refreshing."],
575 typesStorageId,
576 debug: {
577 ...debugBase,
578 mode: "cache-stale",
579 normalizedSourceCount: sources.length,
580 cacheHit: true,
581 cacheFresh: false,
582 timedOutSources: [],
583 durationMs: Date.now() - startedAt,
584 trace,
585 },
586 };
587 }632 }
588 }633 }
589 }634 }
@@ -772,11 +817,10 @@ async function loadWorkspaceToolInventoryForContext( · 182 unmodified lines
772 skipCacheRead,817 skipCacheRead,
773 actorId: context.actorId,818 actorId: context.actorId,
774 }),819 }),
775 ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }),820 listWorkspaceAccessPolicies(ctx, context.workspaceId),
776 ]);821 ]);
777 const typedPolicies: AccessPolicyRecord[] = policies;
778 const descriptorsStartedAt = Date.now();822 const descriptorsStartedAt = Date.now();
779 const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies, {823 const tools = listVisibleToolDescriptors(result.tools, context, policies, {
780 includeDetails,824 includeDetails,
781 toolPaths: options.toolPaths,825 toolPaths: options.toolPaths,
782 });826 });