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