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