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 @@
11 "use node";
22
33 import { WorkOS } from "@workos-inc/node";
4+import { Result } from "better-result";
5+import { z } from "zod";
46 import type { CredentialProvider, CredentialRecord } from "./types";
57 import { asRecord } from "./utils";
68
@@ -18,6 +20,12 @@ type ProviderResolver = ( · 11 unmodified lines
1820 readVaultObject: VaultObjectReader,
1921 ) => Promise<CredentialPayload | null>;
2022
23+const workosVaultSecretConfigSchema = z.object({
24+ objectId: z.string().optional(),
25+ id: z.string().optional(),
26+ apiKey: z.string().optional(),
27+});
28+
2129 function resolveWorkosApiKey(explicitApiKey?: string): string {
2230 const candidate =
2331 explicitApiKey?.trim() ||
@@ -84,14 +92,13 @@ function parseSecretValue(raw: string): CredentialPayload { · 60 unmodified lines
8492 return {};
8593 }
8694
87− try {
88− const parsed = JSON.parse(value);
95+ const parsedJson = Result.try(() => JSON.parse(value));
96+ if (parsedJson.isOk()) {
97+ const parsed = parsedJson.value;
8998 const asObject = asRecord(parsed);
9099 if (Object.keys(asObject).length > 0) {
91100 return asObject;
92101 }
93− } catch {
94− // not JSON; fall through
95102 }
96103
97104 return { token: value };
@@ -107,20 +114,16 @@ async function resolveWorkosVault( · 9 unmodified lines
107114 record: Pick<CredentialRecord, "secretJson">,
108115 readVaultObject: VaultObjectReader,
109116 ): Promise<CredentialPayload | null> {
110− const config = asRecord(record.secretJson);
111− const objectId =
112− typeof config.objectId === "string" && config.objectId.trim()
113− ? config.objectId.trim()
114− : typeof config.id === "string" && config.id.trim()
115− ? config.id.trim()
116− : "";
117+ const parsedConfig = workosVaultSecretConfigSchema.safeParse(asRecord(record.secretJson));
118+ const config = parsedConfig.success ? parsedConfig.data : {};
119+ const objectId = config.objectId?.trim() || config.id?.trim() || "";
117120
118121 if (!objectId) {
119122 throw new Error(
120123 "Encrypted credential is missing its secure reference. Re-save this credential in the dashboard.",
121124 );
122125 }
123− const apiKey = typeof config.apiKey === "string" && config.apiKey.trim() ? config.apiKey.trim() : undefined;
126+ const apiKey = config.apiKey?.trim() || undefined;
124127
125128 const raw = await readVaultObject({ objectId, apiKey });
126129 return parseSecretValue(raw);
executor/packages/core/src/graphql/field-tools.ts+8−10
@@ -1,4 +1,5 @@
11 import type { GraphqlExecutionEnvelope } from "../tool/source-execution";
2+import { asRecord } from "../utils";
23
34 export interface GqlTypeRef {
45 kind: string;
@@ -48,15 +49,12 @@ export function selectGraphqlFieldEnvelope( · 43 unmodified lines
4849 envelope: GraphqlExecutionEnvelope,
4950 operationName: string,
5051 ): GraphqlExecutionEnvelope {
51− const data = envelope.data;
52− if (data && typeof data === "object" && !Array.isArray(data)) {
53− const record = data as Record<string, unknown>;
54− if (Object.prototype.hasOwnProperty.call(record, operationName)) {
55− return {
56− data: record[operationName],
57− errors: envelope.errors,
58− };
59− }
52+ const record = asRecord(envelope.data);
53+ if (Object.prototype.hasOwnProperty.call(record, operationName)) {
54+ return {
55+ data: record[operationName],
56+ errors: envelope.errors,
57+ };
6058 }
6159
6260 return envelope;
@@ -84,7 +82,7 @@ export function normalizeGraphqlFieldVariables( · 21 unmodified lines
8482 && typeof value === "object"
8583 && !Array.isArray(value)
8684 ) {
87− const nested = value as Record<string, unknown>;
85+ const nested = asRecord(value);
8886 if (Object.keys(nested).length === 1 && Object.prototype.hasOwnProperty.call(nested, argName)) {
8987 return { [argName]: nested[argName] };
9088 }
executor/packages/core/src/managed/runtime-bootstrap.ts+15−6
@@ -1,12 +1,21 @@
11 import fs from "node:fs/promises";
22 import os from "node:os";
33 import path from "node:path";
4+import { z } from "zod";
45
56 import type { ManagedRuntimeInfo } from "../managed-runtime";
67 import { managedRuntimeVersions, pathExists } from "./runtime-info";
78 import { ensureConvexCliRuntime } from "./runtime-installation";
89 import { runProcess } from "./runtime-process";
910
11+const adminKeyResponseSchema = z.object({
12+ adminKey: z.string().min(1),
13+});
14+
15+const convexJsonSchema = z.object({
16+ functions: z.string().optional(),
17+});
18+
1019 const BOOTSTRAP_REQUIRED_DEPENDENCIES = [
1120 "convex",
1221 "@convex-dev/migrations",
@@ -41,12 +50,12 @@ async function generateSelfHostedAdminKey(info: ManagedRuntimeInfo): Promise<str · 28 unmodified lines
4150 throw new Error(`Failed generating self-hosted admin key: ${text || response.statusText}`);
4251 }
4352
44− const parsed = (await response.json()) as { adminKey?: string };
45− if (!parsed.adminKey) {
53+ const parsed = adminKeyResponseSchema.safeParse(await response.json());
54+ if (!parsed.success) {
4655 throw new Error("Convex admin key generation did not return an admin key.");
4756 }
4857
49− return parsed.adminKey;
58+ return parsed.data.adminKey;
5059 }
5160
5261 async function hasAnyPath(paths: string[]): Promise<boolean> {
@@ -72,11 +81,11 @@ function getConfigCandidates(baseDir: string): string[] { · 19 unmodified lines
7281 async function getConfiguredFunctionsPath(candidate: string): Promise<string | null> {
7382 try {
7483 const raw = await fs.readFile(path.join(candidate, "convex.json"), "utf8");
75− const parsed = JSON.parse(raw) as { functions?: unknown };
76− if (typeof parsed.functions !== "string") {
84+ const parsed = convexJsonSchema.safeParse(JSON.parse(raw));
85+ if (!parsed.success || typeof parsed.data.functions !== "string") {
7786 return null;
7887 }
79− const value = parsed.functions.trim();
88+ const value = parsed.data.functions.trim();
8089 return value.length > 0 ? value : null;
8190 } catch {
8291 return null;
executor/packages/core/src/managed/runtime-info.ts+27−1
@@ -1,9 +1,19 @@
11 import fs from "node:fs/promises";
22 import os from "node:os";
33 import path from "node:path";
4+import { Result } from "better-result";
5+import { z } from "zod";
46
57 import type { ManagedRuntimeConfig, ManagedRuntimeInfo } from "../managed-runtime";
68
9+const 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+
717 const CONVEX_BACKEND_REPO = "get-convex/convex-backend";
818 const EXECUTOR_RELEASE_REPO = Bun.env.EXECUTOR_REPO ?? "RhysSullivan/executor";
919 const NODE_VERSION = "22.22.0";
@@ -81,6 +91,11 @@ function defaultConfig(): ManagedRuntimeConfig { · 71 unmodified lines
8191 };
8292 }
8393
94+function parseManagedRuntimeConfig(value: unknown): ManagedRuntimeConfig | null {
95+ const parsed = managedRuntimeConfigSchema.safeParse(value);
96+ return parsed.success ? parsed.data : null;
97+}
98+
8499 export async function pathExists(filePath: string): Promise<boolean> {
85100 try {
86101 await fs.access(filePath);
@@ -136,7 +151,18 @@ export function runtimeInfo(): ManagedRuntimeInfo { · 49 unmodified lines
136151 export async function ensureConfig(info: ManagedRuntimeInfo): Promise<ManagedRuntimeConfig> {
137152 if (await pathExists(info.configPath)) {
138153 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+
140166 if (parsed.instanceName === "executor-local") {
141167 parsed.instanceName = "anonymous-executor";
142168 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 @@
11 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
22 import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
33 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4+import { z } from "zod";
5+
6+const mcpToolResultSchema = z.object({
7+ content: z.array(z.object({ text: z.string().optional() }).passthrough()).optional(),
8+}).passthrough();
49
510 function withHeaders(
611 input: RequestInfo | URL,
@@ -50,14 +55,14 @@ export async function connectMcp( · 43 unmodified lines
5055 if (preferredTransport === "streamable-http") {
5156 await client.connect(new StreamableHTTPClientTransport(endpoint, {
5257 requestInit: headers ? { headers } : undefined,
53− }) as Parameters<Client["connect"]>[0]);
58+ }));
5459 return { client, close: () => client.close() };
5560 }
5661
5762 try {
5863 await client.connect(new StreamableHTTPClientTransport(endpoint, {
5964 requestInit: headers ? { headers } : undefined,
60− }) as Parameters<Client["connect"]>[0]);
65+ }));
6166 return { client, close: () => client.close() };
6267 } catch {
6368 await client.connect(new SSEClientTransport(endpoint, {
@@ -73,12 +78,16 @@ export async function connectMcp( · 9 unmodified lines
7378 }
7479
7580 export function extractMcpResult(result: unknown): unknown {
76− if (!result || typeof result !== "object") return result;
77− const content = (result as { content?: unknown }).content;
78− if (!Array.isArray(content)) return result;
81+ const parsed = mcpToolResultSchema.safeParse(result);
82+ if (!parsed.success || !Array.isArray(parsed.data.content)) {
83+ return result;
84+ }
85+
86+ const content = parsed.data.content;
7987 const texts = content
80− .map((item) => (item && typeof item === "object" ? (item as { text?: unknown }).text : undefined))
88+ .map((item) => item.text)
8189 .filter((item): item is string => typeof item === "string");
90+
8291 if (texts.length === 0) return content;
8392 if (texts.length === 1) return texts[0];
8493 return texts;
executor/packages/core/src/mcp/server-approval.ts+28−12
@@ -1,4 +1,5 @@
11 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+import { z } from "zod";
23 import type { PendingApprovalRecord, TaskRecord } from "../types";
34 import type { Id } from "../../../database/convex/_generated/dataModel";
45 import type {
@@ -8,6 +9,15 @@ import type { · 3 unmodified lines
89 McpExecutorService,
910 } from "./server-contracts";
1011 import { getTaskTerminalState } from "./server-utils";
12+import { asRecord } from "../utils";
13+
14+const 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+});
1121
1222 function formatApprovalInput(input: unknown, maxLength = 2000): string {
1323 let serialized: string;
@@ -42,7 +52,7 @@ function buildApprovalPromptMessage(approval: PendingApprovalRecord): string { · 28 unmodified lines
4252
4353 export function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt {
4454 return async (approval) => {
45− const response = await mcp.server.elicitInput({
55+ const rawResponse = await mcp.server.elicitInput({
4656 mode: "form",
4757 message: buildApprovalPromptMessage(approval),
4858 requestedSchema: {
@@ -67,23 +77,31 @@ export function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt { · 18 unmodified lines
6777 },
6878 required: ["decision"],
6979 },
70− }, { timeout: 15_000 }) as {
71− action: "accept" | "decline" | "cancel";
72− content?: Record<string, unknown>;
73− };
80+ }, { timeout: 15_000 });
81+
82+ const parsedResponse = elicitationResponseSchema.safeParse(rawResponse);
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);
7492
75− if (response.action !== "accept") {
93+ if (action !== "accept") {
7694 return {
7795 decision: "denied",
78− reason: response.action === "decline"
96+ reason: action === "decline"
7997 ? "User explicitly declined approval"
8098 : "User canceled approval prompt",
8199 };
82100 }
83101
84− const selectedDecision = response.content?.decision;
102+ const selectedDecision = content.decision;
85103 const decision = selectedDecision === "approved" ? "approved" : "denied";
86− const selectedReason = response.content?.reason;
104+ const selectedReason = content.reason;
87105 const reason = typeof selectedReason === "string" && selectedReason.trim().length > 0
88106 ? selectedReason
89107 : undefined;
@@ -191,9 +209,7 @@ export function waitForTerminalTask( · 101 unmodified lines
191209 };
192210
193211 unsubscribe = service.subscribe(taskId, workspaceId, (event) => {
194− const payload = typeof event.payload === "object" && event.payload
195− ? event.payload as Record<string, unknown>
196− : {};
212+ const payload = asRecord(event.payload);
197213 const type = typeof payload.status === "string" ? payload.status : undefined;
198214 const pendingApprovalCount = typeof payload.pendingApprovalCount === "number"
199215 ? payload.pendingApprovalCount
executor/packages/core/src/openapi-auth.ts+25−7
@@ -1,6 +1,16 @@
11 import type { OpenApiAuth } from "./tool/source-types";
2+import { z } from "zod";
23 import { asRecord } from "./utils";
34
5+const securityRequirementSchema = z.record(z.array(z.unknown()).optional());
6+
7+const 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+
414 export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | undefined {
515 const components = asRecord(spec.components);
616 const securitySchemes = asRecord(components.securitySchemes);
@@ -9,21 +19,29 @@ export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | u · 2 unmodified lines
919 }
1020
1121 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)
1326 : [];
1427
1528 const referencedSchemeName = security
1629 .flatMap((entry) => Object.keys(entry))
17− .find((name) => typeof securitySchemes[name] === "object");
30+ .find((name) => Object.prototype.hasOwnProperty.call(securitySchemes, name));
1831
1932 const schemeName = referencedSchemeName ?? Object.keys(securitySchemes)[0];
2033 if (!schemeName) return undefined;
2134
22− const scheme = asRecord(securitySchemes[schemeName]);
23− const type = String(scheme.type ?? "").toLowerCase();
35+ const parsedScheme = securitySchemeSchema.safeParse(securitySchemes[schemeName]);
36+ if (!parsedScheme.success) {
37+ return undefined;
38+ }
39+
40+ const scheme = parsedScheme.data;
41+ const type = (scheme.type ?? "").toLowerCase();
2442
2543 if (type === "http") {
26− const httpScheme = String(scheme.scheme ?? "").toLowerCase();
44+ const httpScheme = (scheme.scheme ?? "").toLowerCase();
2745 if (httpScheme === "bearer") {
2846 return { type: "bearer", mode: "workspace" };
2947 }
@@ -34,8 +52,8 @@ export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | u · 4 unmodified lines
3452 }
3553
3654 if (type === "apikey") {
37− const location = String(scheme.in ?? "").toLowerCase();
38− const header = typeof scheme.name === "string" ? scheme.name.trim() : "";
55+ const location = (scheme.in ?? "").toLowerCase();
56+ const header = (scheme.name ?? "").trim();
3957 if (location === "header" && header.length > 0) {
4058 return { type: "apiKey", mode: "workspace", header };
4159 }
executor/packages/core/src/openapi-prepare.ts+71−22
@@ -1,29 +1,58 @@
11 import SwaggerParser from "@apidevtools/swagger-parser";
22 import openapiTS, { astToString } from "openapi-typescript";
3+import { Result } from "better-result";
4+import { z } from "zod";
35 import { inferOpenApiAuth } from "./openapi-auth";
46 import { compactOpenApiPaths } from "./openapi-compaction";
57 import { extractOperationIdsFromDts } from "./openapi/schema-hints";
68 import type { PreparedOpenApiSpec } from "./tool/source-types";
79 import { asRecord } from "./utils";
810
11+const unknownRecordSchema = z.record(z.unknown());
12+
913 interface SwaggerParserAdapter {
1014 bundle(spec: unknown): Promise<unknown>;
1115 parse(spec: unknown): Promise<unknown>;
1216 }
1317
1418 function createSwaggerParserAdapter(parserModule: unknown): SwaggerParserAdapter {
15− const parser = parserModule as Partial<SwaggerParserAdapter>;
16− if (typeof parser.parse !== "function" || typeof parser.bundle !== "function") {
19+ if ((typeof parserModule !== "object" || parserModule === null) && typeof parserModule !== "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") {
1726 throw new Error("SwaggerParser module is missing parse/bundle methods");
1827 }
19− const parse = parser.parse!;
20− const bundle = parser.bundle!;
28+
2129 return {
2230 parse: (spec) => parse(spec),
2331 bundle: (spec) => bundle(spec),
2432 };
2533 }
2634
35+function 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+
44+function 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+
2756 function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null {
2857 let strippedCount = 0;
2958
@@ -33,7 +62,7 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 3 unmodified lines
3362 let target: unknown = spec;
3463 for (const segment of segments) {
3564 if (target && typeof target === "object") {
36− target = (target as Record<string, unknown>)[segment];
65+ target = asRecord(target)[segment];
3766 } else {
3867 return false;
3968 }
@@ -44,11 +73,11 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 4 unmodified lines
4473 function hasBrokenDiscriminators(obj: unknown): boolean {
4574 if (Array.isArray(obj)) return obj.some(hasBrokenDiscriminators);
4675 if (obj && typeof obj === "object") {
47− const record = obj as Record<string, unknown>;
76+ const record = asRecord(obj);
4877 if (record.discriminator && typeof record.discriminator === "object") {
49− const disc = record.discriminator as Record<string, unknown>;
78+ const disc = asRecord(record.discriminator);
5079 if (disc.mapping && typeof disc.mapping === "object") {
51− const mapping = disc.mapping as Record<string, string>;
80+ const mapping = asRecord(disc.mapping);
5281 if (Object.values(mapping).some((ref) => typeof ref === "string" && !refExists(ref))) {
5382 return true;
5483 }
@@ -64,13 +93,13 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 9 unmodified lines
6493 function walk(obj: unknown): unknown {
6594 if (Array.isArray(obj)) return obj.map(walk);
6695 if (obj && typeof obj === "object") {
67− const record = obj as Record<string, unknown>;
96+ const record = asRecord(obj);
6897 const clone: Record<string, unknown> = {};
6998 for (const [key, value] of Object.entries(record)) {
7099 if (key === "discriminator" && typeof value === "object" && value !== null) {
71− const disc = value as Record<string, unknown>;
100+ const disc = asRecord(value);
72101 if (disc.mapping && typeof disc.mapping === "object") {
73− const mapping = disc.mapping as Record<string, string>;
102+ const mapping = asRecord(disc.mapping);
74103 const hasBroken = Object.values(mapping).some(
75104 (ref) => typeof ref === "string" && !refExists(ref),
76105 );
@@ -87,7 +116,11 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 10 unmodified lines
87116 return obj;
88117 }
89118
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;
91124 console.warn(`[executor] stripped ${strippedCount} broken discriminator(s) from OpenAPI spec`);
92125 return result;
93126 }
@@ -135,12 +168,18 @@ export async function prepareOpenApiSpec( · 41 unmodified lines
135168
136169 let parsed: Record<string, unknown>;
137170 if (typeof spec === "string") {
138− try {
139− parsed = (await parser.parse(spec)) as Record<string, unknown>;
140− } catch (parseError) {
141− const msg = parseError instanceof Error ? parseError.message : String(parseError);
171+ const parsedResult = await Result.tryPromise(() => parser.parse(spec));
172+ if (parsedResult.isErr()) {
173+ const msg = describeResultError(parsedResult.error);
142174 throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`);
143175 }
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;
144183 } else {
145184 parsed = spec;
146185 }
@@ -150,12 +189,19 @@ export async function prepareOpenApiSpec( · 3 unmodified lines
150189 ? generateOpenApiDts(parsed)
151190 : Promise.resolve<string | null>(null);
152191 if (shouldBundle) {
153− try {
154− bundled = (await parser.bundle(parsed)) as Record<string, unknown>;
155− } catch (bundleError) {
156− const bundleMessage = bundleError instanceof Error ? bundleError.message : String(bundleError);
192+ const bundleResult = await Result.tryPromise(() => parser.bundle(parsed));
193+ if (bundleResult.isErr()) {
194+ const bundleMessage = describeResultError(bundleResult.error);
157195 warnings.push(`OpenAPI bundle failed for '${sourceName}', using parse-only mode: ${bundleMessage}`);
158196 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+ }
159205 }
160206 } else {
161207 bundled = parsed;
@@ -163,12 +209,15 @@ export async function prepareOpenApiSpec( · 1 unmodified line
163209 const dts = await dtsPromise;
164210
165211 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 : [];
167213 const inferredAuth = inferOpenApiAuth(bundled);
168214
169215 return {
170216 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+ })
172221 .filter((url) => url.length > 0),
173222 paths: compactOpenApiPaths(
174223 bundled.paths,
executor/packages/core/src/openapi/schema-hints.ts+8−8
@@ -4,7 +4,7 @@ type JsonSchema = Record<string, unknown>; · 3 unmodified lines
44 const COMPONENT_REF_INLINE_DEPTH = 2;
55
66 function isSmallInlineableComponentSchema(schema: Record<string, unknown>): boolean {
7− const shape = schema as JsonSchema;
7+ const shape = schema;
88 const type = typeof shape.type === "string" ? shape.type : undefined;
99 const props = asRecord(shape.properties);
1010 const propCount = Object.keys(props).length;
@@ -302,8 +302,8 @@ function extractStringLiteralFromSchema(schema: JsonSchema): string | null { · 291 unmodified lines
302302 if (enumValues && enumValues.length === 1 && typeof enumValues[0] === "string") {
303303 return enumValues[0];
304304 }
305− if (typeof (schema as Record<string, unknown>).const === "string") {
306− return String((schema as Record<string, unknown>).const);
305+ if (typeof schema.const === "string") {
306+ return String(schema.const);
307307 }
308308 return null;
309309 }
@@ -342,7 +342,7 @@ function repairMissingRequiredProperties( · 32 unmodified lines
342342 if (!schema || typeof schema !== "object") continue;
343343 const hint = jsonSchemaTypeHintFallback(schema, depth + 1, componentSchemas, seenRefs);
344344 if (!hint || hint === "unknown") continue;
345− candidates.push({ hint, schema: schema as JsonSchema });
345+ candidates.push({ hint, schema: asRecord(schema) });
346346 }
347347
348348 if (candidates.length === 0) continue;
@@ -358,7 +358,7 @@ function repairMissingRequiredProperties( · 9 unmodified lines
358358
359359 for (let i = 0; i < variants.length; i++) {
360360 const variant = variants[i]!;
361− const additionalProperties = (variant as Record<string, unknown>).additionalProperties;
361+ const additionalProperties = variant.additionalProperties;
362362 // Only patch the strict case where the omission would make the schema invalid.
363363 if (additionalProperties !== false) {
364364 out.push(variant);
@@ -389,7 +389,7 @@ function repairMissingRequiredProperties( · 24 unmodified lines
389389 out.push({
390390 ...variant,
391391 properties: nextProps,
392− } as JsonSchema);
392+ });
393393 }
394394
395395 return changed ? out : variants;
@@ -788,7 +788,7 @@ export function jsonSchemaTypeHintFallback( · 392 unmodified lines
788788 if (!schema || typeof schema !== "object") return "unknown";
789789 if (depth > 12) return "unknown";
790790
791− const shape = schema as JsonSchema;
791+ const shape = asRecord(schema);
792792 if (typeof shape.$ref === "string") {
793793 const ref = shape.$ref;
794794 const prefix = "#/components/schemas/";
@@ -973,7 +973,7 @@ function collectTopLevelSchemaKeys( · 178 unmodified lines
973973 ): string[] {
974974 if (!schema || typeof schema !== "object") return [];
975975
976− const record = schema as Record<string, unknown>;
976+ const record = asRecord(schema);
977977 const ref = typeof record.$ref === "string" ? record.$ref : "";
978978 if (ref.startsWith("#/components/schemas/")) {
979979 if (seenRefs.has(ref)) return [];
executor/packages/core/src/openapi/tool-builder.ts+11−7
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 jsonSchemaTypeHintFallback,
77 responseTypeHintFromSchema,
88 } from "./schema-hints";
9+import { z } from "zod";
910 import { buildOpenApiToolPath } from "./tool-path";
1011 import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
1112 import { executeOpenApiRequest } from "../tool/source-execution";
@@ -23,15 +24,20 @@ type OpenApiOperationParameter = { · 11 unmodified lines
2324 schema: Record<string, unknown>;
2425 };
2526
27+const recordArraySchema = z.array(z.record(z.unknown()));
28+
29+function asRecordArray(value: unknown): Record<string, unknown>[] {
30+ const parsed = recordArraySchema.safeParse(value);
31+ return parsed.success ? parsed.data : [];
32+}
33+
2634 function buildOpenApiOperationParameters(
2735 sharedParameters: Array<Record<string, unknown>>,
2836 operation: Record<string, unknown>,
2937 ): OpenApiOperationParameter[] {
3038 return [
3139 ...sharedParameters,
32− ...(Array.isArray(operation.parameters)
33− ? (operation.parameters as Array<Record<string, unknown>>)
34− : []),
40+ ...asRecordArray(operation.parameters),
3541 ].map((entry) => ({
3642 name: String(entry.name ?? ""),
3743 in: String(entry.in ?? "query"),
@@ -63,15 +69,13 @@ export function buildOpenApiToolsFromPrepared( · 25 unmodified lines
6369
6470 for (const [pathTemplate, pathValue] of Object.entries(paths)) {
6571 const pathObject = asRecord(pathValue);
66− const sharedParameters = Array.isArray(pathObject.parameters)
67− ? (pathObject.parameters as Array<Record<string, unknown>>)
68− : [];
72+ const sharedParameters = asRecordArray(pathObject.parameters);
6973
7074 for (const method of methods) {
7175 const operation = asRecord(pathObject[method]);
7276 if (Object.keys(operation).length === 0) continue;
7377
74− const tags = Array.isArray(operation.tags) ? (operation.tags as unknown[]) : [];
78+ const tags = Array.isArray(operation.tags) ? operation.tags : [];
7579 const tagRaw = String(tags[0] ?? "default");
7680 const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`);
7781 const parameters = buildOpenApiOperationParameters(sharedParameters, operation);
executor/packages/core/src/postman-runtime.ts+16−5
@@ -1,4 +1,5 @@
11 import { asStringRecord, detectJsonContentType, findUnresolvedPostmanTemplateKeys, interpolatePostmanTemplate, stringifyTemplateValue } from "./postman-utils";
2+import { z } from "zod";
23 import { asRecord } from "./utils";
34 import type { PostmanRequestBody } from "./postman/collection-utils";
45
@@ -13,14 +14,24 @@ export interface PostmanSerializedRunSpec { · 8 unmodified lines
1314 authHeaders: Record<string, string>;
1415 }
1516
17+const 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+
1624 export async function executePostmanRequest(
1725 runSpec: PostmanSerializedRunSpec,
1826 payload: Record<string, unknown>,
1927 credentialHeaders?: Record<string, string>,
2028 ): Promise<unknown> {
29+ const parsedPayload = postmanRuntimePayloadSchema.safeParse(payload);
30+ const normalizedPayload = parsedPayload.success ? parsedPayload.data : {};
31+
2132 const variables = {
2233 ...runSpec.variables,
23− ...asStringRecord(payload.variables),
34+ ...asStringRecord(normalizedPayload.variables),
2435 };
2536
2637 const interpolatedUrl = interpolatePostmanTemplate(runSpec.url, variables);
@@ -44,7 +55,7 @@ export async function executePostmanRequest( · 17 unmodified lines
4455 }
4556 }
4657
47− const queryOverrides = asRecord(payload.query);
58+ const queryOverrides = asRecord(normalizedPayload.query);
4859 for (const [key, value] of Object.entries(queryOverrides)) {
4960 if (!key || value === undefined || value === null) continue;
5061 url.searchParams.set(key, stringifyTemplateValue(value));
@@ -58,7 +69,7 @@ export async function executePostmanRequest( · 7 unmodified lines
5869 Object.assign(headers, runSpec.authHeaders);
5970 Object.assign(headers, credentialHeaders ?? {});
6071
61− const headerOverrides = asRecord(payload.headers);
72+ const headerOverrides = asRecord(normalizedPayload.headers);
6273 for (const [name, value] of Object.entries(headerOverrides)) {
6374 if (!name || value === undefined || value === null) continue;
6475 headers[name] = stringifyTemplateValue(value);
@@ -69,9 +80,9 @@ export async function executePostmanRequest( · 4 unmodified lines
6980 let body: string | undefined;
7081
7182 if (!readMethods.has(method)) {
72− const hasExplicitBody = Object.prototype.hasOwnProperty.call(payload, "body");
83+ const hasExplicitBody = Object.prototype.hasOwnProperty.call(normalizedPayload, "body");
7384 if (hasExplicitBody) {
74− const bodyValue = payload.body;
85+ const bodyValue = normalizedPayload.body;
7586 if (typeof bodyValue === "string") {
7687 body = bodyValue;
7788 } else if (bodyValue !== undefined) {
executor/packages/core/src/postman-utils.ts+3−2
@@ -1,3 +1,5 @@
1+import { asRecord } from "./utils";
2+
13 const POSTMAN_TEMPLATE_PATTERN = /\{\{([^{}]+)\}\}/g;
24
35 export function stringifyTemplateValue(value: unknown): string {
@@ -26,9 +28,8 @@ export function findUnresolvedPostmanTemplateKeys(value: string): string[] { · 22 unmodified lines
2628 }
2729
2830 export function asStringRecord(value: unknown): Record<string, string> {
29− if (!value || typeof value !== "object" || Array.isArray(value)) return {};
3031 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))) {
3233 result[key] = stringifyTemplateValue(entry);
3334 }
3435 return result;
executor/packages/core/src/postman/collection-utils.ts+5−8
@@ -1,5 +1,6 @@
11 import { sanitizeSegment, sanitizeSnakeSegment } from "../tool/path-utils";
22 import { stringifyTemplateValue } from "../postman-utils";
3+import { asRecord } from "../utils";
34
45 export type PostmanRequestBody =
56 | { kind: "urlencoded"; entries: Array<{ key: string; value: string }> }
@@ -50,8 +51,7 @@ export function extractPostmanVariableMap(value: unknown): Record<string, string · 44 unmodified lines
5051 if (!Array.isArray(value)) return {};
5152 const result: Record<string, string> = {};
5253 for (const entry of value) {
53− if (!entry || typeof entry !== "object") continue;
54− const record = entry as Record<string, unknown>;
54+ const record = asRecord(entry);
5555 const key = typeof record.key === "string" ? record.key.trim() : "";
5656 if (!key) continue;
5757 if (record.disabled === true) continue;
@@ -64,8 +64,7 @@ export function extractPostmanHeaderMap(value: unknown): Record<string, string> · 6 unmodified lines
6464 if (!Array.isArray(value)) return {};
6565 const result: Record<string, string> = {};
6666 for (const entry of value) {
67− if (!entry || typeof entry !== "object") continue;
68− const record = entry as Record<string, unknown>;
67+ const record = asRecord(entry);
6968 const key = typeof record.key === "string" ? record.key.trim() : "";
7069 if (!key || record.disabled === true) continue;
7170 result[key] = stringifyTemplateValue(record.value);
@@ -77,8 +76,7 @@ export function extractPostmanQueryEntries(value: unknown): Array<{ key: string; · 5 unmodified lines
7776 if (!Array.isArray(value)) return [];
7877 const entries: Array<{ key: string; value: string }> = [];
7978 for (const entry of value) {
80− if (!entry || typeof entry !== "object") continue;
81− const record = entry as Record<string, unknown>;
79+ const record = asRecord(entry);
8280 const key = typeof record.key === "string" ? record.key.trim() : "";
8381 if (!key || record.disabled === true) continue;
8482 entries.push({ key, value: stringifyTemplateValue(record.value) });
@@ -91,8 +89,7 @@ export function extractPostmanBody(record: Record<string, unknown>): PostmanRequ · 6 unmodified lines
9189 if (dataMode === "urlencoded" && Array.isArray(record.data)) {
9290 const entries: Array<{ key: string; value: string }> = [];
9391 for (const item of record.data) {
94− if (!item || typeof item !== "object") continue;
95− const entry = item as Record<string, unknown>;
92+ const entry = asRecord(item);
9693 const key = typeof entry.key === "string" ? entry.key.trim() : "";
9794 if (!key || entry.disabled === true) continue;
9895 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
4848 return null;
4949 }
5050
51− return Result.try(() => JSON.parse(serialized.value) as unknown).unwrapOr(String(value));
51+ return Result.try(() => JSON.parse(serialized.value)).unwrapOr(String(value));
5252 }
5353
5454 function createToolsProxy(
executor/packages/core/src/tool-discovery.ts+35−11
@@ -1,4 +1,5 @@
11 import type { ToolDefinition } from "./types";
2+import { z } from "zod";
23 import {
34 compactDescriptionLine,
45 } from "./type-hints";
@@ -11,6 +12,23 @@ import { buildIndex, getTopLevelNamespace, listIndexForContext } from "./tool-di · 6 unmodified lines
1112 import { chooseBestPath, deriveIntentPhrase, extractNamespaceHints, scoreEntry } from "./tool-discovery/ranking";
1213 import { asRecord } from "./utils";
1314
15+const 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+
24+const 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+
1432 export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] {
1533 const index = buildIndex(tools);
1634
@@ -70,12 +88,15 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 53 unmodified lines
7088 },
7189 run: async (input: unknown, context) => {
7290 const payload = asRecord(input);
73− const namespaceFilter = String(payload.namespace ?? "").trim().toLowerCase();
74− const query = String(payload.query ?? "").trim().toLowerCase();
75− const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));
76− const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));
77− const compact = payload.compact === false ? false : true;
78− const includeSchemas = payload.includeSchemas === true;
91+ const parsedInput = catalogToolsInputSchema.safeParse(payload);
92+ const namespaceFilter = (parsedInput.success ? (parsedInput.data.namespace ?? "") : "").trim().toLowerCase();
93+ const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase();
94+ const depthValue = parsedInput.success ? parsedInput.data.depth : undefined;
95+ const depth = Math.max(0, Math.min(2, Number(depthValue ?? 1)));
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;
79100 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];
80101
81102 const visible = listIndexForContext(index, context.isToolAllowed);
@@ -157,11 +178,14 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 75 unmodified lines
157178 },
158179 run: async (input: unknown, context) => {
159180 const payload = asRecord(input);
160− const query = String(payload.query ?? "").trim().toLowerCase();
161− const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));
162− const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));
163− const compact = payload.compact === false ? false : true;
164− const includeSchemas = payload.includeSchemas === true;
181+ const parsedInput = discoverInputSchema.safeParse(payload);
182+ const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase();
183+ const depthValue = parsedInput.success ? parsedInput.data.depth : undefined;
184+ const depth = Math.max(0, Math.min(2, Number(depthValue ?? 1)));
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;
165189 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];
166190 const namespaces = new Set(index.map((entry) => getTopLevelNamespace(entry.path)).filter(Boolean));
167191 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
105105 const aliases = getPathAliases(tool.path);
106106 const searchText = `${tool.path} ${preferredPath} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();
107107
108− const inputSchema = (tool.typing?.inputSchema && typeof tool.typing.inputSchema === "object")
109− ? tool.typing.inputSchema
110− : {};
111− const outputSchema = (tool.typing?.outputSchema && typeof tool.typing.outputSchema === "object")
112− ? tool.typing.outputSchema
113− : {};
108+ const inputSchema = asRecord(tool.typing?.inputSchema);
109+ const outputSchema = asRecord(tool.typing?.outputSchema);
114110
115111 const safeInputSchema = sanitizeJsonSchemaForConvex(inputSchema);
116112 const safeOutputSchema = sanitizeJsonSchemaForConvex(outputSchema);
executor/packages/core/src/tool-source-loaders/graphql-loader.ts+98−14
@@ -1,5 +1,7 @@
11 "use node";
22
3+import { Result } from "better-result";
4+import { z } from "zod";
35 import {
46 buildFieldQuery,
57 normalizeGraphqlFieldVariables,
@@ -19,6 +21,57 @@ import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/sc · 13 unmodified lines
1921 import type { ToolDefinition } from "../types";
2022 import { asRecord } from "../utils";
2123
24+const gqlTypeRefSchema: z.ZodType<GqlTypeRef> = z.lazy(() => z.object({
25+ kind: z.string(),
26+ name: z.string().nullable(),
27+ ofType: gqlTypeRefSchema.nullish(),
28+}));
29+
30+const 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+
37+const 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+
44+const 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+
51+const gqlEnumValueSchema = z.object({
52+ name: z.string(),
53+ description: z.string().nullable().optional().transform((value) => value ?? null),
54+});
55+
56+const 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+
64+const 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+
70+const gqlIntrospectionResponseSchema = z.object({
71+ data: z.object({ __schema: gqlSchemaSchema }).optional(),
72+ errors: z.array(z.unknown()).optional(),
73+});
74+
2275 const INTROSPECTION_QUERY = `
2376 query IntrospectionQuery {
2477 __schema {
@@ -147,6 +200,33 @@ function buildArgsObjectSchema( · 122 unmodified lines
147200 };
148201 }
149202
203+function 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+
222+function 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+
150230 export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> {
151231 const authHeaders = buildStaticAuthHeaders(config.auth);
152232 const sourceKey = `graphql:${config.name}`;
@@ -168,14 +248,19 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 15 unmodified lines
168248 throw new Error(`GraphQL introspection failed: HTTP ${introspectionResult.status}: ${text.slice(0, 300)}`);
169249 }
170250
171− const introspectionJson = (await introspectionResult.json()) as { data?: { __schema?: GqlSchema }; errors?: unknown[] };
172− if (introspectionJson.errors) {
173− throw new Error(`GraphQL introspection errors: ${JSON.stringify(introspectionJson.errors).slice(0, 500)}`);
251+ const introspectionJsonResult = await Result.tryPromise(() => introspectionResult.json());
252+ if (introspectionJsonResult.isErr()) {
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}`);
174257 }
175− const schema = introspectionJson.data?.__schema;
176− if (!schema) {
177− throw new Error("GraphQL introspection returned no schema");
258+
259+ const schemaResult = parseGraphqlIntrospectionSchema(introspectionJsonResult.value);
260+ if (schemaResult.isErr()) {
261+ throw schemaResult.error;
178262 }
263+ const schema = schemaResult.value;
179264
180265 // Index types by name
181266 const typeMap = new Map<string, GqlType>();
@@ -187,7 +272,7 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 5 unmodified lines
187272
188273 // Create the main graphql tool — this is the one that actually executes queries
189274 const mainToolPath = `${sourceName}.graphql`;
190− tools.push({
275+ const mainTool: ToolDefinition & { _graphqlSource: string } = {
191276 path: mainToolPath,
192277 source: sourceKey,
193278 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
236321 context.credential?.headers,
237322 );
238323 },
239− } as ToolDefinition & { _graphqlSource: string });
324+ };
325+ tools.push(mainTool);
240326
241327 // Create pseudo-tools for each query/mutation field — these are for discovery/intellisense
242328 // but they all route through the main .graphql tool
@@ -269,7 +355,7 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 26 unmodified lines
269355 ? `tools.${fieldPath}({})`
270356 : `tools.${fieldPath}({ ${field.args.map((arg) => `${arg.name}: ...`).join(", ")} })`;
271357
272− tools.push({
358+ const fieldTool: ToolDefinition & { _pseudoTool: boolean } = {
273359 path: fieldPath,
274360 source: sourceKey,
275361 description: field.description
@@ -315,13 +401,11 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 39 unmodified lines
315401 );
316402 }
317403 }
318− // Find and invoke the main tool
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;
404+ const envelope = toGraphqlEnvelope(await mainTool.run(payload, context));
322405 return selectGraphqlFieldEnvelope(envelope, field.name);
323406 },
324− } as ToolDefinition & { _pseudoTool: boolean });
407+ };
408+ tools.push(fieldTool);
325409 }
326410 }
327411
executor/packages/core/src/tool-source-loaders/mcp-loader.ts+15−3
@@ -1,5 +1,6 @@
11 "use node";
22
3+import { z } from "zod";
34 import { connectMcp, extractMcpResult } from "../mcp-runtime";
45 import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
56 import { callMcpToolWithReconnect } from "../tool/source-execution";
@@ -10,6 +11,19 @@ import type { ToolDefinition } from "../types"; · 4 unmodified lines
1011 import { asRecord } from "../utils";
1112 import type { SerializedTool } from "../tool/source-serialization";
1213
14+const listedToolsResponseSchema = z.object({
15+ tools: z.array(z.record(z.unknown())).optional(),
16+});
17+
18+function 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+
1327 export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDefinition[]> {
1428 const queryParams = config.queryParams
1529 ? Object.fromEntries(
@@ -50,9 +64,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 34 unmodified lines
5064 }
5165
5266 const listed = await connection.client.listTools();
53− const tools = Array.isArray((listed as { tools?: unknown }).tools)
54− ? ((listed as { tools: Array<Record<string, unknown>> }).tools)
55− : [];
67+ const tools = extractListedTools(listed);
5668
5769 return tools.map((tool) => {
5870 const toolName = String(tool.name ?? "tool");
executor/packages/core/src/tool-source-loaders/openapi-loader.ts+26−8
@@ -1,5 +1,7 @@
11 "use node";
22
3+import { Result } from "better-result";
4+import { z } from "zod";
35 import { buildOpenApiToolsFromPrepared } from "../openapi/tool-builder";
46 import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
57 import {
@@ -19,6 +21,14 @@ import type { SerializedTool } from "../tool/source-serialization"; · 13 unmodified lines
1921
2022 const POSTMAN_SPEC_PREFIX = "postman:";
2123 const DEFAULT_POSTMAN_PROXY_URL = "https://www.postman.com/_api/ws/proxy";
24+const recordArraySchema = z.array(z.record(z.unknown()));
25+const 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+});
2232
2333 function parsePostmanCollectionUid(spec: string): string | null {
2434 if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) {
@@ -57,15 +67,23 @@ async function loadPostmanCollectionTools( · 32 unmodified lines
5767 throw new Error(`Failed to fetch API collection ${collectionUid}: HTTP ${response.status} ${text.slice(0, 300)}`);
5868 }
5969
60− const raw = await response.json() as Record<string, unknown>;
61− const collection = asRecord(raw.data);
62− const requests = Array.isArray(collection.requests)
63− ? collection.requests.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object"))
64− : [];
70+ const responseJsonResult = await Result.tryPromise(() => response.json());
71+ if (responseJsonResult.isErr()) {
72+ const cause = responseJsonResult.error.cause;
73+ const message = cause instanceof Error ? cause.message : String(cause);
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+ }
6581
66− const folders = Array.isArray(collection.folders)
67− ? collection.folders.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object"))
68− : [];
82+ const collection = parsedCollection.data.data ?? {};
83+ const requestsResult = recordArraySchema.safeParse(collection.requests);
84+ const requests = requestsResult.success ? requestsResult.data : [];
85+ const foldersResult = recordArraySchema.safeParse(collection.folders);
86+ const folders = foldersResult.success ? foldersResult.data : [];
6987
7088 const folderById = new Map<string, { name: string; parentId?: string }>();
7189 for (const folder of folders) {
executor/packages/core/src/tool-sources.ts+61−1
@@ -1,10 +1,12 @@
11 "use node";
22
3+import { Result } from "better-result";
4+import { z } from "zod";
35 import { loadGraphqlTools } from "./tool-source-loaders/graphql-loader";
46 import { loadMcpTools } from "./tool-source-loaders/mcp-loader";
57 import { loadOpenApiTools } from "./tool-source-loaders/openapi-loader";
68 import { buildOpenApiToolsFromPrepared } from "./openapi/tool-builder";
7−import { rehydrateTools, serializeTools, type SerializedTool } from "./tool/source-serialization";
9+import { parseSerializedTool, rehydrateTools, serializeTools, type SerializedTool } from "./tool/source-serialization";
810 import type {
911 ExternalToolSourceConfig,
1012 OpenApiToolSourceConfig,
@@ -32,6 +34,64 @@ export interface CompiledToolSourceArtifact { · 21 unmodified lines
3234 tools: SerializedTool[];
3335 }
3436
37+const 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+
44+const workspaceToolSnapshotSchema = z.object({
45+ version: z.literal("v2"),
46+ externalArtifacts: z.array(z.unknown()),
47+ warnings: z.array(z.string()),
48+});
49+
50+export 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+
73+export 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+
3595 async function loadSourceToolDefinitions(source: ExternalToolSourceConfig): Promise<ToolDefinition[]> {
3696 if (source.type === "mcp") {
3797 return await loadMcpTools(source);
executor/packages/core/src/tool-typing/convex-sanitize.ts+2−1
@@ -1,4 +1,5 @@
11 import type { JsonSchema } from "../types";
2+import { asRecord } from "../utils";
23
34 function stripDollarKeysDeep(value: unknown): unknown {
45 if (Array.isArray(value)) {
@@ -7,7 +8,7 @@ function stripDollarKeysDeep(value: unknown): unknown { · 2 unmodified lines
78
89 if (value && typeof value === "object") {
910 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))) {
1112 if (key.startsWith("$")) continue;
1213 out[key] = stripDollarKeysDeep(child);
1314 }
executor/packages/core/src/tool-typing/schema-utils.ts+17−16
@@ -5,31 +5,32 @@ function uniq(values: string[]): string[] { · 4 unmodified lines
55 return [...new Set(values.filter((v) => v.trim().length > 0))];
66 }
77
8−function collectTopLevelRequiredKeys(schema: JsonSchema, out: string[]): void {
9− const required = Array.isArray((schema as Record<string, unknown>).required)
10− ? ((schema as Record<string, unknown>).required as unknown[]).filter((v): v is string => typeof v === "string")
11− : [];
8+function asUnknownArray(value: unknown): unknown[] {
9+ return Array.isArray(value) ? value : [];
10+}
11+
12+function collectTopLevelRequiredKeys(schema: Record<string, unknown>, out: string[]): void {
13+ const required = asUnknownArray(schema.required)
14+ .filter((value): value is string => typeof value === "string");
1215 out.push(...required);
1316
14− const allOf = Array.isArray((schema as Record<string, unknown>).allOf)
15− ? ((schema as Record<string, unknown>).allOf as unknown[])
16− : [];
17+ const allOf = asUnknownArray(schema.allOf);
1718 for (const entry of allOf) {
18− if (!entry || typeof entry !== "object") continue;
19− collectTopLevelRequiredKeys(entry as JsonSchema, out);
19+ const nested = asRecord(entry);
20+ if (Object.keys(nested).length === 0) continue;
21+ collectTopLevelRequiredKeys(nested, out);
2022 }
2123 }
2224
23−function collectTopLevelPropertyKeys(schema: JsonSchema, out: string[]): void {
24− const props = asRecord((schema as Record<string, unknown>).properties);
25+function collectTopLevelPropertyKeys(schema: Record<string, unknown>, out: string[]): void {
26+ const props = asRecord(schema.properties);
2527 out.push(...Object.keys(props));
2628
27− const allOf = Array.isArray((schema as Record<string, unknown>).allOf)
28− ? ((schema as Record<string, unknown>).allOf as unknown[])
29− : [];
29+ const allOf = asUnknownArray(schema.allOf);
3030 for (const entry of allOf) {
31− if (!entry || typeof entry !== "object") continue;
32− collectTopLevelPropertyKeys(entry as JsonSchema, out);
31+ const nested = asRecord(entry);
32+ if (Object.keys(nested).length === 0) continue;
33+ collectTopLevelPropertyKeys(nested, out);
3334 }
3435 }
3536
executor/packages/core/src/tool-typing/typebundle.ts+2−1
@@ -1,6 +1,7 @@
11 import type { ToolDefinition } from "../types";
22 import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
33 import { OPENAPI_HELPER_TYPES } from "../openapi/helper-types";
4+import { asRecord } from "../utils";
45 import { BASE_ENVIRONMENT_DTS } from "./env-types";
56
67 const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i;
@@ -30,7 +31,7 @@ function wrapDtsInNamespace(namespace: string, rawDts: string): string { · 23 unmodified lines
3031 function typeHintFromSchema(schema: Record<string, unknown> | undefined, fallback: string): string {
3132 if (!schema || Object.keys(schema).length === 0) return fallback;
3233 // 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);
3435 const required = Array.isArray(schema.required) ? schema.required : [];
3536 if (Object.keys(props).length === 0 && required.length === 0) {
3637 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
22 import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime";
33 import { normalizeGraphqlFieldVariables, selectGraphqlFieldEnvelope } from "../graphql/field-tools";
44 import { callMcpToolWithReconnect, executeGraphqlRequest, executeOpenApiRequest } from "./source-execution";
5+import { Result } from "better-result";
6+import { z } from "zod";
57 import type { ToolApprovalMode, ToolCredentialSpec, ToolDefinition, ToolTyping } from "../types";
68 import { asRecord } from "../utils";
79
@@ -49,6 +51,116 @@ export interface SerializedTool { · 41 unmodified lines
4951 | { kind: "builtin" };
5052 }
5153
54+const 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+
68+const 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+
77+const 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+
91+const graphqlRawRunSpecSchema = z.object({
92+ kind: z.literal("graphql_raw"),
93+ endpoint: z.string(),
94+ authHeaders: z.record(z.string()),
95+});
96+
97+const 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+
107+const builtinRunSpecSchema = z.object({ kind: z.literal("builtin") });
108+
109+const toolTypedRefSchema = z.object({
110+ kind: z.literal("openapi_operation"),
111+ sourceKey: z.string(),
112+ operationId: z.string(),
113+});
114+
115+const 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+
125+const 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+
133+const graphqlRawInputSchema = z.object({
134+ query: z.string(),
135+ variables: z.unknown().optional(),
136+});
137+
138+const graphqlFieldInputSchema = z.object({
139+ query: z.string().optional(),
140+ variables: z.unknown().optional(),
141+}).passthrough();
142+
143+const serializedRunSpecSchema = z.union([
144+ openApiRunSpecSchema,
145+ mcpRunSpecSchema,
146+ postmanRunSpecSchema,
147+ graphqlRawRunSpecSchema,
148+ graphqlFieldRunSpecSchema,
149+ builtinRunSpecSchema,
150+]);
151+
152+const 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+
52164 type ToolWithRunSpec = ToolDefinition & { _runSpec?: SerializedTool["runSpec"] };
53165 type McpConnection = Awaited<ReturnType<typeof connectMcp>>;
54166 type McpConnectionCacheEntry = { promise: Promise<McpConnection> };
@@ -99,13 +211,41 @@ export function serializeTools(tools: ToolDefinition[]): SerializedTool[] { · 44 unmodified lines
99211 }));
100212 }
101213
214+export 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+
102223 export function rehydrateTools(
103− serialized: SerializedTool[],
224+ serialized: ReadonlyArray<unknown>,
104225 baseTools: Map<string, ToolDefinition>,
105226 ): ToolDefinition[] {
106227 const mcpConnections = new Map<string, McpConnectionCacheEntry>();
107228
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;
109249 const base: Omit<ToolDefinition, "run"> = {
110250 path: st.path,
111251 description: st.description,
@@ -191,11 +331,12 @@ export function rehydrateTools( · 79 unmodified lines
191331 ...base,
192332 run: async (input: unknown, context) => {
193333 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 : "";
195336 if (!query.trim()) {
196337 throw new Error("GraphQL query string is required");
197338 }
198− const variables = payload.variables;
339+ const variables = parsedInput.success ? parsedInput.data.variables : undefined;
199340 const response = await executeGraphqlRequest(endpoint, authHeaders, query, variables, context.credential?.headers);
200341 if (response.isErr()) {
201342 throw new Error(response.error.message);
@@ -211,10 +352,12 @@ export function rehydrateTools( · 9 unmodified lines
211352 ...base,
212353 run: async (input: unknown, context) => {
213354 const payload = asRecord(input);
214− const hasExplicitQuery = typeof payload.query === "string" && payload.query.trim().length > 0;
215− const query = hasExplicitQuery ? String(payload.query) : queryTemplate;
355+ const parsedInput = graphqlFieldInputSchema.safeParse(payload);
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;
216359
217− let variables = payload.variables;
360+ let variables = normalizedInput.variables;
218361 if (variables === undefined && !hasExplicitQuery) {
219362 variables = normalizeGraphqlFieldVariables(argNames ?? [], payload);
220363 }
executor/packages/core/src/workspace/tool-cache.test.ts+42−11
@@ -10,10 +10,10 @@ import { · 9 unmodified lines
1010 prepareOpenApiSpec,
1111 buildOpenApiToolsFromPrepared,
1212 materializeWorkspaceSnapshot,
13+ parseWorkspaceToolSnapshot,
1314 serializeTools,
1415 rehydrateTools,
1516 type CompiledToolSourceArtifact,
16− type SerializedTool,
1717 type WorkspaceToolSnapshot,
1818 } from "../tool-sources";
1919 import type { ToolDefinition } from "../types";
@@ -184,7 +184,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 164 unmodified lines
184184
185185 // JSON round-trip (simulates storage)
186186 const json = JSON.stringify(serialized);
187− const restored = JSON.parse(json) as SerializedTool[];
187+ const restored = JSON.parse(json);
188188 expect(restored.length).toBe(4);
189189
190190 // Rehydrate
@@ -214,7 +214,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 23 unmodified lines
214214
215215 const serialized = serializeTools(tools);
216216 const json = JSON.stringify(serialized);
217− const restored = JSON.parse(json) as SerializedTool[];
217+ const restored = JSON.parse(json);
218218 const rehydrated = rehydrateTools(restored, makeBaseTools());
219219
220220 for (let i = 0; i < tools.length; i++) {
@@ -235,7 +235,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 14 unmodified lines
235235 expect(serialized[0]!.runSpec.kind).toBe("builtin");
236236
237237 const json = JSON.stringify(serialized);
238− const restored = JSON.parse(json) as SerializedTool[];
238+ const restored = JSON.parse(json);
239239 const rehydrated = rehydrateTools(restored, baseTools);
240240
241241 expect(rehydrated.length).toBe(1);
@@ -279,7 +279,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 37 unmodified lines
279279
280280 // JSON round-trip
281281 const json = JSON.stringify(serialized);
282− const restored = JSON.parse(json) as SerializedTool[];
282+ const restored = JSON.parse(json);
283283 expect(restored[0]!.runSpec.kind).toBe("mcp");
284284 });
285285
@@ -327,7 +327,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 41 unmodified lines
327327 expect(serialized[0]!.runSpec.kind).toBe("graphql_raw");
328328 expect(serialized[1]!.runSpec.kind).toBe("graphql_field");
329329
330− const restored = JSON.parse(JSON.stringify(serialized)) as SerializedTool[];
330+ const restored = JSON.parse(JSON.stringify(serialized));
331331 const rehydrated = rehydrateTools(restored, makeBaseTools());
332332
333333 const rawTool = rehydrated.find((tool) => tool.path === "linear.graphql");
@@ -401,13 +401,17 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 67 unmodified lines
401401 const json = JSON.stringify(snapshot);
402402 const blob = new Blob([json], { type: "application/json" });
403403 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+ }
405409
406− expect(restored.warnings).toEqual(["test warning"]);
407− expect(restored.version).toBe("v2");
408− expect(restored.externalArtifacts).toHaveLength(1);
410+ expect(restored.value.warnings).toEqual(["test warning"]);
411+ expect(restored.value.version).toBe("v2");
412+ expect(restored.value.externalArtifacts).toHaveLength(1);
409413
410− const restoredTools = materializeWorkspaceSnapshot(restored);
414+ const restoredTools = materializeWorkspaceSnapshot(restored.value);
411415 expect(restoredTools.length).toBe(tools.length);
412416 expect(restoredTools.some((tool) => tool.path === "echo")).toBe(false);
413417
@@ -482,6 +486,33 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 68 unmodified lines
482486 // Should be well under 1MB for 100 tools
483487 expect(json.length).toBeLessThan(1_000_000);
484488 });
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+ });
485516 });
486517
487518 describe("workspace tool cache table", () => {
executor/packages/database/src/auth/event_handlers.ts+22−11
@@ -1,4 +1,5 @@
11 import { type AuthKit } from "@convex-dev/workos-authkit";
2+import { z } from "zod";
23 import type { DataModel, Doc } from "../../convex/_generated/dataModel.d.ts";
34 import type { MutationCtx } from "../../convex/_generated/server";
45 import { upsertWorkosAccount } from "./accounts";
@@ -16,7 +17,18 @@ import { · 11 unmodified lines
1617 } from "./memberships";
1718 import { ensureUniqueOrganizationSlug } from "./naming";
1819 import { seedWorkspaceMembersFromOrganization } from "./workspace_membership_projection";
19−import { asPayload } from "../lib/object";
20+
21+const trimmedStringSchema = z.string().transform((value) => value.trim());
22+
23+const 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+});
2032
2133 type WorkosMembershipEventData = {
2234 id: string;
@@ -35,20 +47,19 @@ type ResolvedMembershipTarget = { · 12 unmodified lines
3547 };
3648
3749 function parseMembershipEventData(value: unknown): WorkosMembershipEventData | null {
38− const record = asPayload(value);
39− if (typeof record.id !== "string" || record.id.trim().length === 0) {
50+ const parsed = workosMembershipEventDataSchema.safeParse(value);
51+ if (!parsed.success) {
4052 return null;
4153 }
4254
43− const role = asPayload(record.role);
4455 return {
45− id: record.id,
46− user_id: typeof record.user_id === "string" ? record.user_id : undefined,
47− userId: typeof record.userId === "string" ? record.userId : undefined,
48− organization_id: typeof record.organization_id === "string" ? record.organization_id : undefined,
49− organizationId: typeof record.organizationId === "string" ? record.organizationId : undefined,
50− role: typeof role.slug === "string" ? { slug: role.slug } : undefined,
51− status: typeof record.status === "string" ? record.status : undefined,
56+ id: parsed.data.id,
57+ user_id: parsed.data.user_id,
58+ userId: parsed.data.userId,
59+ organization_id: parsed.data.organization_id,
60+ organizationId: parsed.data.organizationId,
61+ role: parsed.data.role?.slug ? { slug: parsed.data.role.slug } : undefined,
62+ status: parsed.data.status,
5263 };
5364 }
5465
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
4949 );
5050 }
5151 });
52+
53+test("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+
67+test("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 @@
11 import { Result } from "better-result";
2+import { z } from "zod";
23 import type {
34 GraphqlToolSourceConfig,
45 McpToolSourceConfig,
@@ -30,6 +31,29 @@ export type NormalizedToolSourceConfig = · 25 unmodified lines
3031 | NormalizedGraphqlToolSourceConfig
3132 | NormalizedOpenApiToolSourceConfig;
3233
34+const approvalModeSchema = z.enum(["auto", "required"]);
35+const authModeSchema = z.enum(["static", "workspace", "actor"]);
36+const mcpTransportSchema = z.enum(["sse", "streamable-http"]);
37+const overrideEntrySchema = z.object({
38+ approval: approvalModeSchema.optional(),
39+});
40+const unknownRecordSchema = z.record(z.unknown());
41+const stringMapSchema = z.record(z.string());
42+const basicAuthSchema = z.object({
43+ mode: authModeSchema.optional(),
44+ username: z.string().optional(),
45+ password: z.string().optional(),
46+});
47+const bearerAuthSchema = z.object({
48+ mode: authModeSchema.optional(),
49+ token: z.string().optional(),
50+});
51+const apiKeyAuthSchema = z.object({
52+ mode: authModeSchema.optional(),
53+ header: z.string(),
54+ value: z.string().optional(),
55+});
56+
3357 function optionalTrimmedString(value: unknown): string | undefined {
3458 if (typeof value !== "string") return undefined;
3559 const trimmed = value.trim();
@@ -53,7 +77,17 @@ function normalizeStringMap( · 17 unmodified lines
5377 ): Result<Record<string, string> | undefined, Error> {
5478 if (value === undefined) return Result.ok(undefined);
5579
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;
5791 if (Object.keys(record).length === 0) {
5892 return Result.ok(undefined);
5993 }
@@ -62,9 +96,6 @@ function normalizeStringMap( · 2 unmodified lines
6296 for (const [key, rawValue] of Object.entries(record)) {
6397 const normalizedKey = key.trim();
6498 if (!normalizedKey) continue;
65− if (typeof rawValue !== "string") {
66− return Result.err(new Error(`Tool source ${fieldName}.${normalizedKey} must be a string`));
67− }
6899 normalized[normalizedKey] = rawValue;
69100 }
70101
@@ -76,9 +107,12 @@ function normalizeApprovalMode( · 5 unmodified lines
76107 fieldName: string,
77108 ): Result<ToolApprovalMode | undefined, Error> {
78109 if (value === undefined) return Result.ok(undefined);
79− if (value === "auto" || value === "required") {
80− return Result.ok(value);
110+
111+ const parsed = approvalModeSchema.safeParse(value);
112+ if (parsed.success) {
113+ return Result.ok(parsed.data);
81114 }
115+
82116 return Result.err(new Error(`Tool source ${fieldName} must be 'auto' or 'required'`));
83117 }
84118
@@ -88,7 +122,12 @@ function normalizeOverrides( · 3 unmodified lines
88122 ): Result<Record<string, { approval?: ToolApprovalMode }> | undefined, Error> {
89123 if (value === undefined) return Result.ok(undefined);
90124
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;
92131 if (Object.keys(raw).length === 0) {
93132 return Result.ok(undefined);
94133 }
@@ -97,27 +136,18 @@ function normalizeOverrides( · 2 unmodified lines
97136 for (const [rawKey, rawValue] of Object.entries(raw)) {
98137 const key = rawKey.trim();
99138 if (!key) continue;
100− const entry = asRecord(rawValue);
101− const approvalResult = normalizeApprovalMode(entry.approval, `${fieldName}.${key}.approval`);
102− if (approvalResult.isErr()) {
103− return approvalResult;
139+
140+ const parsedEntry = overrideEntrySchema.safeParse(rawValue);
141+ if (!parsedEntry.success) {
142+ return Result.err(new Error(`Tool source ${fieldName}.${key}.approval must be 'auto' or 'required'`));
104143 }
105− normalized[key] = approvalResult.value ? { approval: approvalResult.value } : {};
144+
145+ normalized[key] = parsedEntry.data.approval ? { approval: parsedEntry.data.approval } : {};
106146 }
107147
108148 return Result.ok(Object.keys(normalized).length > 0 ? normalized : undefined);
109149 }
110150
111−function 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−
121151 function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> {
122152 if (value === undefined) return Result.ok(undefined);
123153
@@ -131,39 +161,53 @@ function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> { · 7 unmodified lines
131161 return Result.ok({ type: "none" });
132162 }
133163
134− const modeResult = normalizeAuthMode(auth.mode);
135− if (modeResult.isErr()) {
136− return modeResult;
137− }
138− const mode = modeResult.value;
139−
140164 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+
141170 return Result.ok({
142171 type: "basic",
143− mode,
144− username: optionalTrimmedString(auth.username),
145− password: optionalTrimmedString(auth.password),
172+ mode: parsed.data.mode,
173+ username: optionalTrimmedString(parsed.data.username),
174+ password: optionalTrimmedString(parsed.data.password),
146175 });
147176 }
148177
149178 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+
150184 return Result.ok({
151185 type: "bearer",
152− mode,
153− token: optionalTrimmedString(auth.token),
186+ mode: parsed.data.mode,
187+ token: optionalTrimmedString(parsed.data.token),
154188 });
155189 }
156190
157191 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");
159202 if (headerResult.isErr()) {
160203 return headerResult;
161204 }
205+
162206 return Result.ok({
163207 type: "apiKey",
164− mode,
208+ mode: parsed.data.mode,
165209 header: headerResult.value,
166− value: optionalTrimmedString(auth.value),
210+ value: optionalTrimmedString(parsed.data.value),
167211 });
168212 }
169213
@@ -173,15 +217,21 @@ function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> { · 3 unmodified lines
173217 function normalizeSpec(
174218 value: unknown,
175219 ): Result<string | Record<string, unknown>, Error> {
176− if (typeof value === "string") {
177− const trimmed = value.trim();
220+ const stringSpec = z.string().safeParse(value);
221+ if (stringSpec.success) {
222+ const trimmed = stringSpec.data.trim();
178223 if (!trimmed) {
179224 return Result.err(new Error("Tool source spec is required"));
180225 }
181226 return Result.ok(trimmed);
182227 }
183228
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;
185235 if (Object.keys(specObject).length === 0) {
186236 return Result.err(new Error("Tool source spec must be a non-empty string or object"));
187237 }
@@ -209,7 +259,11 @@ export function normalizeToolSourceConfig( · 21 unmodified lines
209259 type: ToolSourceType,
210260 rawConfig: unknown,
211261 ): 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;
213267
214268 if (type === "mcp") {
215269 const urlResult = requiredTrimmedString(config.url, "url");
@@ -217,10 +271,18 @@ export function normalizeToolSourceConfig( · 1 unmodified line
217271 return urlResult;
218272 }
219273
220− const transport = config.transport;
221− if (transport !== undefined && transport !== "sse" && transport !== "streamable-http") {
274+ const transportResult = config.transport === undefined
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()) {
222283 return Result.err(new Error("Tool source transport must be 'sse' or 'streamable-http'"));
223284 }
285+ const transport = transportResult.value;
224286
225287 const authResult = normalizeAuth(config.auth);
226288 if (authResult.isErr()) {
@@ -290,7 +352,8 @@ export function normalizeToolSourceConfig( · 63 unmodified lines
290352 return overridesResult;
291353 }
292354
293− const schema = asRecord(config.schema);
355+ const schemaResult = unknownRecordSchema.safeParse(config.schema);
356+ const schema = schemaResult.success ? schemaResult.data : {};
294357
295358 return Result.ok({
296359 endpoint: endpointResult.value,
executor/packages/database/src/runtime/tool_call_credentials.ts+34−8
@@ -1,6 +1,7 @@
11 "use node";
22
33 import { Result } from "better-result";
4+import { z } from "zod";
45 import type { ActionCtx } from "../../convex/_generated/server";
56 import { internal } from "../../convex/_generated/api";
67 import { resolveCredentialPayload } from "../../../core/src/credential-providers";
@@ -8,6 +9,25 @@ import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredential · 1 unmodified line
89 import { ToolCallControlError } from "../../../core/src/tool-call-control";
910 import { asPayload } from "../lib/object";
1011
12+const bearerSecretSchema = z.object({
13+ token: z.coerce.string().optional(),
14+});
15+
16+const apiKeySecretSchema = z.object({
17+ headerName: z.coerce.string().optional(),
18+ value: z.coerce.string().optional(),
19+ token: z.coerce.string().optional(),
20+});
21+
22+const basicSecretSchema = z.object({
23+ username: z.coerce.string().optional(),
24+ password: z.coerce.string().optional(),
25+});
26+
27+const credentialOverrideHeadersSchema = z.object({
28+ headers: z.record(z.coerce.string()).optional(),
29+});
30+
1131 export async function resolveCredentialHeaders(
1232 ctx: ActionCtx,
1333 spec: ToolCredentialSpec,
@@ -30,26 +50,32 @@ export async function resolveCredentialHeaders( · 16 unmodified lines
3050
3151 const headers: Record<string, string> = {};
3252 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() : "";
3455 if (token) headers.authorization = `Bearer ${token}`;
3556 } else if (spec.authType === "apiKey") {
36− const headerName = spec.headerName ?? String(sourcePayload.headerName ?? "x-api-key");
37− const value = String(sourcePayload.value ?? sourcePayload.token ?? "").trim();
57+ const parsedSecret = apiKeySecretSchema.safeParse(sourcePayload);
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+ : "";
3863 if (value) headers[headerName] = value;
3964 } else if (spec.authType === "basic") {
40− const username = String(sourcePayload.username ?? "");
41− const password = String(sourcePayload.password ?? "");
65+ const parsedSecret = basicSecretSchema.safeParse(sourcePayload);
66+ const username = parsedSecret.success ? (parsedSecret.data.username ?? "") : "";
67+ const password = parsedSecret.success ? (parsedSecret.data.password ?? "") : "";
4268 if (username || password) {
4369 const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
4470 headers.authorization = `Basic ${encoded}`;
4571 }
4672 }
4773
48− const bindingOverrides = asPayload(record?.overridesJson ?? {});
49− const overrideHeaders = asPayload(bindingOverrides.headers);
74+ const bindingOverrides = credentialOverrideHeadersSchema.safeParse(record?.overridesJson ?? {});
75+ const overrideHeaders = bindingOverrides.success ? (bindingOverrides.data.headers ?? {}) : {};
5076 for (const [key, value] of Object.entries(overrideHeaders)) {
5177 if (!key) continue;
52− headers[key] = String(value);
78+ headers[key] = value;
5379 }
5480
5581 if (Object.keys(headers).length === 0) {
executor/packages/database/src/runtime/tool_call_resolution.ts+29−11
@@ -1,11 +1,12 @@
11 "use node";
22
33 import { Result } from "better-result";
4+import { z } from "zod";
45 import type { ActionCtx } from "../../convex/_generated/server";
56 import { internal } from "../../convex/_generated/api";
67 import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths";
78 import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
8−import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization";
9+import { parseSerializedTool, rehydrateTools } from "../../../core/src/tool/source-serialization";
910 import { asPayload } from "../lib/object";
1011 import { getDecisionForContext, getToolDecision } from "./policy";
1112 import { getReadyRegistryBuildId } from "./tool_registry_state";
@@ -17,20 +18,31 @@ type RegistrySerializedToolEntry = { · 5 unmodified lines
1718 serializedToolJson: string;
1819 };
1920
21+const registrySearchEntrySchema = z.object({
22+ preferredPath: z.string(),
23+});
24+
25+const registrySerializedToolEntrySchema = z.object({
26+ path: z.string(),
27+ serializedToolJson: z.string(),
28+});
29+
2030 async function searchRegistryEntries(
2131 ctx: ActionCtx,
2232 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number },
2333 ): Promise<Array<{ preferredPath: string }>> {
24− const entries: Array<{ preferredPath: string }> = await ctx.runQuery(internal.toolRegistry.searchTools, args);
25− return entries;
34+ const entries = await ctx.runQuery(internal.toolRegistry.searchTools, args);
35+ const parsed = z.array(registrySearchEntrySchema).safeParse(entries);
36+ return parsed.success ? parsed.data : [];
2637 }
2738
2839 async function getRegistryToolByPath(
2940 ctx: ActionCtx,
3041 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; path: string },
3142 ): Promise<RegistrySerializedToolEntry | null> {
32− const entry: RegistrySerializedToolEntry | null = await ctx.runQuery(internal.toolRegistry.getToolByPath, args);
33− return entry;
43+ const entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, args);
44+ const parsed = registrySerializedToolEntrySchema.safeParse(entry);
45+ return parsed.success ? parsed.data : null;
3446 }
3547
3648 async function getRegistryToolsByNormalizedPath(
@@ -42,8 +54,9 @@ async function getRegistryToolsByNormalizedPath( · 5 unmodified lines
4254 limit: number;
4355 },
4456 ): Promise<RegistrySerializedToolEntry[]> {
45− const entries: RegistrySerializedToolEntry[] = await ctx.runQuery(internal.toolRegistry.getToolsByNormalizedPath, args);
46− return entries;
57+ const entries = await ctx.runQuery(internal.toolRegistry.getToolsByNormalizedPath, args);
58+ const parsed = z.array(registrySerializedToolEntrySchema).safeParse(entries);
59+ return parsed.success ? parsed.data : [];
4760 }
4861
4962 export function getGraphqlDecision(
@@ -177,16 +190,21 @@ export async function resolveToolForCall( · 127 unmodified lines
177190 }
178191
179192 const serializedResult = Result.try(() => {
180− const serialized: SerializedTool = JSON.parse(entry.serializedToolJson);
181− return serialized;
193+ return JSON.parse(entry.serializedToolJson);
182194 });
183195 if (serializedResult.isErr()) {
184196 return Result.err(
185197 new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serializedResult.error.message}`),
186198 );
187199 }
188− const serialized = serializedResult.value;
189− const [tool] = rehydrateTools([serialized], baseTools);
200+ const serialized = parseSerializedTool(serializedResult.value);
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);
190208 if (!tool) {
191209 return Result.err(new Error(`Failed to rehydrate tool: ${resolvedToolPath}`));
192210 }
executor/packages/database/src/runtime/tool_invocation.ts+70−19
@@ -1,5 +1,6 @@
11 "use node";
22
3+import { z } from "zod";
34 import type { ActionCtx } from "../../convex/_generated/server";
45 import { internal } from "../../convex/_generated/api";
56 import type {
@@ -40,6 +41,50 @@ type RegistryToolEntry = { · 34 unmodified lines
4041 displayOutput?: string;
4142 };
4243
44+const 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+
56+const registryNamespaceSchema = z.object({
57+ namespace: z.string(),
58+ toolCount: z.number(),
59+ samplePaths: z.array(z.string()),
60+});
61+
62+const 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+
72+const catalogNamespacesInputSchema = z.object({
73+ limit: z.coerce.number().optional(),
74+});
75+
76+const catalogToolsInputSchema = z.object({
77+ namespace: z.string().optional(),
78+ query: z.string().optional(),
79+ limit: z.coerce.number().optional(),
80+});
81+
82+const discoverInputSchema = z.object({
83+ query: z.string().optional(),
84+ limit: z.coerce.number().optional(),
85+ compact: z.boolean().optional(),
86+});
87+
4388 async function upsertRequestedToolCall(
4489 ctx: ActionCtx,
4590 args: { taskId: string; callId: string; workspaceId: TaskRecord["workspaceId"]; toolPath: string },
@@ -52,36 +97,36 @@ async function listWorkspaceAccessPolicies( · 6 unmodified lines
5297 ctx: ActionCtx,
5398 workspaceId: TaskRecord["workspaceId"],
5499 ): Promise<AccessPolicyRecord[]> {
55− const policies: AccessPolicyRecord[] = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId });
56− return policies;
100+ const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId });
101+ const parsed = z.array(accessPolicyRecordSchema).safeParse(policies);
102+ return parsed.success ? parsed.data : [];
57103 }
58104
59105 async function listRegistryNamespaces(
60106 ctx: ActionCtx,
61107 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; limit: number },
62108 ): Promise<Array<{ namespace: string; toolCount: number; samplePaths: string[] }>> {
63− const namespaces: Array<{
64− namespace: string;
65− toolCount: number;
66− samplePaths: string[];
67− }> = await ctx.runQuery(internal.toolRegistry.listNamespaces, args);
68− return namespaces;
109+ const namespaces = await ctx.runQuery(internal.toolRegistry.listNamespaces, args);
110+ const parsed = z.array(registryNamespaceSchema).safeParse(namespaces);
111+ return parsed.success ? parsed.data : [];
69112 }
70113
71114 async function searchRegistryTools(
72115 ctx: ActionCtx,
73116 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number },
74117 ): Promise<RegistryToolEntry[]> {
75− const entries: RegistryToolEntry[] = await ctx.runQuery(internal.toolRegistry.searchTools, args);
76− return entries;
118+ const entries = await ctx.runQuery(internal.toolRegistry.searchTools, args);
119+ const parsed = z.array(registryToolEntrySchema).safeParse(entries);
120+ return parsed.success ? parsed.data : [];
77121 }
78122
79123 async function listRegistryToolsByNamespace(
80124 ctx: ActionCtx,
81125 args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; namespace: string; limit: number },
82126 ): Promise<RegistryToolEntry[]> {
83− const entries: RegistryToolEntry[] = await ctx.runQuery(internal.toolRegistry.listToolsByNamespace, args);
84− return entries;
127+ const entries = await ctx.runQuery(internal.toolRegistry.listToolsByNamespace, args);
128+ const parsed = z.array(registryToolEntrySchema).safeParse(entries);
129+ return parsed.success ? parsed.data : [];
85130 }
86131
87132 async function denyToolCallForApproval(
@@ -168,7 +213,9 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 80 unmodified lines
168213 };
169214
170215 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)));
172219 const namespaces = await listRegistryNamespaces(ctx, {
173220 workspaceId: task.workspaceId,
174221 buildId,
@@ -178,9 +225,11 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 3 unmodified lines
178225 }
179226
180227 if (toolPath === "catalog.tools") {
181− const namespace = String(payload.namespace ?? "").trim().toLowerCase();
182− const query = String(payload.query ?? "").trim();
183− const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));
228+ const parsedInput = catalogToolsInputSchema.safeParse(payload);
229+ const namespace = (parsedInput.success ? (parsedInput.data.namespace ?? "") : "").trim().toLowerCase();
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)));
184233
185234 const raw = query
186235 ? await searchRegistryTools(ctx, {
@@ -219,9 +268,11 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 32 unmodified lines
219268 }
220269
221270 // discover
222− const query = String(payload.query ?? "").trim();
223− const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));
224− const compact = payload.compact === false ? false : true;
271+ const parsedInput = discoverInputSchema.safeParse(payload);
272+ const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim();
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;
225276 const hits = await searchRegistryTools(ctx, {
226277 workspaceId: task.workspaceId,
227278 buildId,
executor/packages/database/src/runtime/tool_registry_state.ts+22−15
@@ -1,9 +1,9 @@
11 "use node";
22
3+import { z } from "zod";
34 import type { ActionCtx } from "../../convex/_generated/server";
45 import type { Id } from "../../convex/_generated/dataModel.d.ts";
56 import { internal } from "../../convex/_generated/api";
6−import { asPayload } from "../lib/object";
77 import { sourceSignature } from "./tool_source_loading";
88
99 export const TOOL_REGISTRY_SIGNATURE_PREFIX = "toolreg_v2|";
@@ -27,19 +27,26 @@ type ToolSourceState = { · 17 unmodified lines
2727 enabled: boolean;
2828 };
2929
30−function toRegistryState(value: unknown): RegistryState {
31− if (!value || typeof value !== "object" || Array.isArray(value)) {
32− return null;
33− }
30+const registryStateSchema = z.object({
31+ signature: z.string(),
32+ readyBuildId: z.string().optional(),
33+});
3434
35− const state = asPayload(value);
36− if (typeof state.signature !== "string") {
35+const toolSourceStateSchema = z.object({
36+ id: z.string(),
37+ updatedAt: z.number(),
38+ enabled: z.boolean().optional(),
39+});
40+
41+function toRegistryState(value: unknown): RegistryState {
42+ const parsed = registryStateSchema.safeParse(value);
43+ if (!parsed.success) {
3744 return null;
3845 }
3946
4047 return {
41− signature: state.signature,
42− readyBuildId: typeof state.readyBuildId === "string" ? state.readyBuildId : undefined,
48+ signature: parsed.data.signature,
49+ readyBuildId: parsed.data.readyBuildId,
4350 };
4451 }
4552
@@ -50,13 +57,13 @@ function toToolSourceStateList(value: unknown): ToolSourceState[] { · 4 unmodified lines
5057
5158 const normalized: ToolSourceState[] = [];
5259 for (const entry of value) {
53− const record = asPayload(entry);
54− if (typeof record.id !== "string") continue;
55− if (typeof record.updatedAt !== "number") continue;
60+ const parsed = toolSourceStateSchema.safeParse(entry);
61+ if (!parsed.success) continue;
62+
5663 normalized.push({
57− id: record.id,
58− updatedAt: record.updatedAt,
59− enabled: record.enabled !== false,
64+ id: parsed.data.id,
65+ updatedAt: parsed.data.updatedAt,
66+ enabled: parsed.data.enabled !== false,
6067 });
6168 }
6269
executor/packages/database/src/runtime/tool_source_loading.ts+96−42
@@ -1,6 +1,7 @@
11 "use node";
22
33 import { Result } from "better-result";
4+import { z } from "zod";
45 import type { ActionCtx } from "../../convex/_generated/server";
56 import { internal } from "../../convex/_generated/api";
67 import type { Id } from "../../convex/_generated/dataModel.d.ts";
@@ -22,37 +23,79 @@ import type { · 15 unmodified lines
2223 } from "../../../core/src/tool/source-types";
2324 import type { ToolSourceRecord } from "../../../core/src/types";
2425 import { normalizeToolSourceConfig } from "../database/tool_source_config";
25−import { asPayload } from "../lib/object";
2626
2727 const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
2828
2929 /** Cache version - bump when tool snapshot/registry/type-hint semantics change. */
3030 const TOOL_SOURCE_CACHE_VERSION = "v25";
3131
32+const openApiAuthModeSchema = z.enum(["static", "workspace", "actor"]);
33+
34+const 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+
55+const bearerSecretSchema = z.object({ token: z.coerce.string().optional() });
56+const apiKeySecretSchema = z.object({
57+ value: z.coerce.string().optional(),
58+ token: z.coerce.string().optional(),
59+});
60+const basicSecretSchema = z.object({
61+ username: z.coerce.string().optional(),
62+ password: z.coerce.string().optional(),
63+});
64+const credentialOverrideHeadersSchema = z.object({
65+ headers: z.record(z.coerce.string()).optional(),
66+});
67+
68+const 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+
77+const 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+
3284 function toPreparedOpenApiSpec(value: unknown): PreparedOpenApiSpec | null {
33− const record = asPayload(value);
34− if (!Array.isArray(record.servers)) {
35− return null;
36− }
37− if (!record.paths || typeof record.paths !== "object" || Array.isArray(record.paths)) {
85+ const parsed = preparedOpenApiSpecSchema.safeParse(value);
86+ if (!parsed.success) {
3887 return null;
3988 }
4089
41− const servers = record.servers.filter((entry): entry is string => typeof entry === "string");
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;
90+ const valueRecord = parsed.data;
4991
5092 return {
51− servers,
52− paths: asPayload(record.paths),
53− warnings,
54− dts: typeof record.dts === "string" ? record.dts : undefined,
55− dtsStatus,
93+ servers: valueRecord.servers,
94+ paths: valueRecord.paths,
95+ warnings: valueRecord.warnings ?? [],
96+ dts: valueRecord.dts,
97+ dtsStatus: valueRecord.dtsStatus,
98+ inferredAuth: valueRecord.inferredAuth,
5699 };
57100 }
58101
@@ -69,20 +112,25 @@ export function normalizeExternalToolSource(raw: { · 10 unmodified lines
69112 name: string;
70113 config: Record<string, unknown>;
71114 }): 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;
72120
73− if (raw.type === "mcp") {
74− const configResult = normalizeToolSourceConfig("mcp", raw.config);
121+ if (normalizedRaw.type === "mcp") {
122+ const configResult = normalizeToolSourceConfig("mcp", normalizedRaw.config);
75123 if (configResult.isErr()) {
76124 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}`),
78126 );
79127 }
80128 const config = configResult.value;
81129 const result: McpToolSourceConfig = {
82130 type: "mcp",
83− name: raw.name,
84− sourceId: raw.id,
85− sourceKey: `source:${raw.id}`,
131+ name: normalizedRaw.name,
132+ sourceId: normalizedRaw.id,
133+ sourceKey: `source:${normalizedRaw.id}`,
86134 url: config.url,
87135 auth: config.auth,
88136 transport: config.transport,
@@ -93,19 +141,19 @@ export function normalizeExternalToolSource(raw: { · 4 unmodified lines
93141 return Result.ok(result);
94142 }
95143
96− if (raw.type === "graphql") {
97− const configResult = normalizeToolSourceConfig("graphql", raw.config);
144+ if (normalizedRaw.type === "graphql") {
145+ const configResult = normalizeToolSourceConfig("graphql", normalizedRaw.config);
98146 if (configResult.isErr()) {
99147 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}`),
101149 );
102150 }
103151 const config = configResult.value;
104152 const result: GraphqlToolSourceConfig = {
105153 type: "graphql",
106− name: raw.name,
107− sourceId: raw.id,
108− sourceKey: `source:${raw.id}`,
154+ name: normalizedRaw.name,
155+ sourceId: normalizedRaw.id,
156+ sourceKey: `source:${normalizedRaw.id}`,
109157 endpoint: config.endpoint,
110158 schema: config.schema,
111159 auth: config.auth,
@@ -116,19 +164,19 @@ export function normalizeExternalToolSource(raw: { · 4 unmodified lines
116164 return Result.ok(result);
117165 }
118166
119− const configResult = normalizeToolSourceConfig("openapi", raw.config);
167+ const configResult = normalizeToolSourceConfig("openapi", normalizedRaw.config);
120168 if (configResult.isErr()) {
121169 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}`),
123171 );
124172 }
125173 const config = configResult.value;
126174
127175 const result: OpenApiToolSourceConfig = {
128176 type: "openapi",
129− name: raw.name,
130− sourceId: raw.id,
131− sourceKey: `source:${raw.id}`,
177+ name: normalizedRaw.name,
178+ sourceId: normalizedRaw.id,
179+ sourceKey: `source:${normalizedRaw.id}`,
132180 spec: config.spec,
133181 collectionUrl: config.collectionUrl,
134182 postmanProxyUrl: config.postmanProxyUrl,
@@ -146,18 +194,23 @@ function buildHeadersFromCredentialSecret( · 11 unmodified lines
146194 secret: Record<string, unknown>,
147195 ): Record<string, string> {
148196 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() : "";
150199 return token ? { authorization: `Bearer ${token}` } : {};
151200 }
152201
153202 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+ : "";
155207 return value ? { [auth.header]: value } : {};
156208 }
157209
158210 if (auth.type === "basic") {
159− const username = String(secret.username ?? "");
160− const password = String(secret.password ?? "");
211+ const parsedSecret = basicSecretSchema.safeParse(secret);
212+ const username = parsedSecret.success ? (parsedSecret.data.username ?? "") : "";
213+ const password = parsedSecret.success ? (parsedSecret.data.password ?? "") : "";
161214 if (!username && !password) {
162215 return {};
163216 }
@@ -215,10 +268,11 @@ async function resolveMcpDiscoveryHeaders( · 51 unmodified lines
215268 }
216269
217270 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 ?? {}) : {};
219273 for (const [key, value] of Object.entries(overrideHeaders)) {
220274 if (!key) continue;
221− headers[key] = String(value);
275+ headers[key] = value;
222276 }
223277
224278 if (Object.keys(headers).length === 0) {
executor/packages/database/src/runtime/workspace_tools.ts+82−38
@@ -1,5 +1,7 @@
11 "use node";
22
3+import { Result } from "better-result";
4+import { z } from "zod";
35 import type { ActionCtx } from "../../convex/_generated/server";
46 import { internal } from "../../convex/_generated/api";
57 import type { Id } from "../../convex/_generated/dataModel.d.ts";
@@ -8,6 +10,7 @@ import { jsonSchemaTypeHintFallback } from "../../../core/src/openapi/schema-hin · 2 unmodified lines
810 import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../../core/src/tool-typing/schema-utils";
911 import {
1012 materializeCompiledToolSource,
13+ parseWorkspaceToolSnapshot,
1114 materializeWorkspaceSnapshot,
1215 type CompiledToolSourceArtifact,
1316 type WorkspaceToolSnapshot,
@@ -21,6 +24,7 @@ import type { · 7 unmodified lines
2124 SourceAuthProfile,
2225 ToolDefinition,
2326 ToolDescriptor,
27+ ToolSourceRecord,
2428 } from "../../../core/src/types";
2529 import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";
2630 import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
@@ -30,6 +34,41 @@ import { asPayload } from "../lib/object"; · 3 unmodified lines
3034
3135 const baseTools = new Map<string, ToolDefinition>();
3236
37+const adminAnnouncementInputSchema = z.object({
38+ channel: z.string().optional(),
39+ message: z.string().optional(),
40+});
41+
42+async 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+
50+async 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+
58+async 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+
3372 // Minimal built-in tools used by tests/demos.
3473 // These are intentionally simple and are always approval-gated.
3574 baseTools.set("admin.send_announcement", {
@@ -59,9 +98,9 @@ baseTools.set("admin.send_announcement", { · 23 unmodified lines
5998 },
6099 },
61100 run: async (input: unknown) => {
62− const payload = asPayload(input);
63− const channel = typeof payload.channel === "string" ? payload.channel : "";
64− const message = typeof payload.message === "string" ? payload.message : "";
101+ const parsedInput = adminAnnouncementInputSchema.safeParse(asPayload(input));
102+ const channel = parsedInput.success ? (parsedInput.data.channel ?? "") : "";
103+ const message = parsedInput.success ? (parsedInput.data.message ?? "") : "";
65104 return { ok: true, channel, message };
66105 },
67106 });
@@ -508,9 +547,9 @@ export async function getWorkspaceTools( · 440 unmodified lines
508547 const sourceTimeoutMs = options.sourceTimeoutMs;
509548 const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false;
510549 const actorId = options.actorId;
511− const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))
512− .filter((source: { enabled: boolean }) => source.enabled);
513− const hasActorScopedMcpSource = sources.some((source: { type: string; config: Record<string, unknown> }) => {
550+ const sources = (await listWorkspaceToolSources(ctx, workspaceId))
551+ .filter((source) => source.enabled);
552+ const hasActorScopedMcpSource = sources.some((source) => {
514553 if (source.type !== "mcp") {
515554 return false;
516555 }
@@ -520,7 +559,7 @@ export async function getWorkspaceTools( · 3 unmodified lines
520559 const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource;
521560 const skipCacheWrite = hasActorScopedMcpSource;
522561 traceStep("listToolSources", listSourcesStartedAt);
523− const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi");
562+ const hasOpenApiSource = sources.some((source) => source.type === "openapi");
524563 const signature = sourceSignature(workspaceId, sources);
525564 const registrySignature = registrySignatureForWorkspace(workspaceId, sources);
526565 const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = {
@@ -543,47 +582,53 @@ export async function getWorkspaceTools( · 16 unmodified lines
543582 const cacheHydrateStartedAt = Date.now();
544583 const blob = await ctx.storage.get(cacheEntry.storageId);
545584 if (blob) {
546− const snapshot: WorkspaceToolSnapshot = JSON.parse(await blob.text());
547− const restored = materializeWorkspaceSnapshot(snapshot);
548− const merged = mergeTools(restored);
549− traceStep("cacheHydrate", cacheHydrateStartedAt);
550−
551− const typesStorageId: Id<"_storage"> | undefined = cacheEntry.typesStorageId;
552− if (cacheEntry.isFresh) {
553− if (typesStorageId) {
585+ const parsedSnapshot = await parseWorkspaceToolSnapshotFromBlob(blob);
586+ if (parsedSnapshot.isErr()) {
587+ trace.push("cacheHydrate=invalidSnapshot");
588+ console.warn(`[executor] invalid workspace tool cache snapshot for '${workspaceId}': ${parsedSnapshot.error.message}`);
589+ } else {
590+ const snapshot = parsedSnapshot.value;
591+ const restored = materializeWorkspaceSnapshot(snapshot);
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) {
554616 return {
555617 tools: merged,
556− warnings: snapshot.warnings,
618+ warnings: [...snapshot.warnings, "Tool sources changed; showing previous results while refreshing."],
557619 typesStorageId,
558620 debug: {
559621 ...debugBase,
560− mode: "cache-fresh",
622+ mode: "cache-stale",
561623 normalizedSourceCount: sources.length,
562624 cacheHit: true,
563− cacheFresh: true,
625+ cacheFresh: false,
564626 timedOutSources: [],
565627 durationMs: Date.now() - startedAt,
566628 trace,
567629 },
568630 };
569631 }
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− };
587632 }
588633 }
589634 }
@@ -772,11 +817,10 @@ async function loadWorkspaceToolInventoryForContext( · 182 unmodified lines
772817 skipCacheRead,
773818 actorId: context.actorId,
774819 }),
775− ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }),
820+ listWorkspaceAccessPolicies(ctx, context.workspaceId),
776821 ]);
777− const typedPolicies: AccessPolicyRecord[] = policies;
778822 const descriptorsStartedAt = Date.now();
779− const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies, {
823+ const tools = listVisibleToolDescriptors(result.tools, context, policies, {
780824 includeDetails,
781825 toolPaths: options.toolPaths,
782826 });