Unified · Split
| @@ -163,11 +163,7 @@ function createRunCodeTool( · 162 unmodified lines | |||
| 163 | // Input schema — when context is bound, workspace fields aren't needed | 163 | // Input schema — when context is bound, workspace fields aren't needed |
| 164 | // --------------------------------------------------------------------------- | 164 | // --------------------------------------------------------------------------- |
| 165 | 165 | ||
| 166 | function toAnySchema(schema: unknown): AnySchema { | 166 | const FULL_INPUT: AnySchema = z.object({ |
| 167 | return schema as AnySchema; | ||
| 168 | } | ||
| 169 | |||
| 170 | const FULL_INPUT = toAnySchema(z.object({ | ||
| 171 | code: z.string().min(1), | 167 | code: z.string().min(1), |
| 172 | timeoutMs: z.number().int().min(1).max(600_000).optional(), | 168 | timeoutMs: z.number().int().min(1).max(600_000).optional(), |
| 173 | runtimeId: z.string().optional(), | 169 | runtimeId: z.string().optional(), |
| @@ -176,14 +172,14 @@ const FULL_INPUT = toAnySchema(z.object({ · 2 unmodified lines | |||
| 176 | sessionId: z.string().optional(), | 172 | sessionId: z.string().optional(), |
| 177 | waitForResult: z.boolean().optional(), | 173 | waitForResult: z.boolean().optional(), |
| 178 | resultTimeoutMs: z.number().int().min(100).max(900_000).optional(), | 174 | resultTimeoutMs: z.number().int().min(100).max(900_000).optional(), |
| 179 | })); | 175 | }); |
| 180 | 176 | ||
| 181 | const BOUND_INPUT = toAnySchema(z.object({ | 177 | const BOUND_INPUT: AnySchema = z.object({ |
| 182 | code: z.string().min(1), | 178 | code: z.string().min(1), |
| 183 | timeoutMs: z.number().int().min(1).max(600_000).optional(), | 179 | timeoutMs: z.number().int().min(1).max(600_000).optional(), |
| 184 | runtimeId: z.string().optional(), | 180 | runtimeId: z.string().optional(), |
| 185 | metadata: z.record(z.string(), z.any()).optional(), | 181 | metadata: z.record(z.string(), z.any()).optional(), |
| 186 | })); | 182 | }); |
| 187 | 183 | ||
| 188 | // --------------------------------------------------------------------------- | 184 | // --------------------------------------------------------------------------- |
| 189 | // MCP server factory | 185 | // MCP server factory |
| @@ -207,13 +203,7 @@ async function createMcpServer( · 17 unmodified lines | |||
| 207 | }, | 203 | }, |
| 208 | ); | 204 | ); |
| 209 | const onApprovalPrompt = createMcpApprovalPrompt(mcp); | 205 | const onApprovalPrompt = createMcpApprovalPrompt(mcp); |
| 210 | const registerTool = (mcp.registerTool as ( | 206 | mcp.registerTool( |
| 211 | name: string, | ||
| 212 | config: { description: string; inputSchema: AnySchema }, | ||
| 213 | cb: ReturnType<typeof createRunCodeTool>, | ||
| 214 | ) => void).bind(mcp); | ||
| 215 | |||
| 216 | registerTool( | ||
| 217 | "run_code", | 207 | "run_code", |
| 218 | { | 208 | { |
| 219 | description: buildRunCodeDescription(), | 209 | description: buildRunCodeDescription(), |
| @@ -17,9 +17,7 @@ export const createTaskEvent = internalMutation({ · 16 unmodified lines | |||
| 17 | throw new Error(`Task not found for event: ${args.taskId}`); | 17 | throw new Error(`Task not found for event: ${args.taskId}`); |
| 18 | } | 18 | } |
| 19 | 19 | ||
| 20 | const currentSequence = typeof (task as { nextEventSequence?: unknown }).nextEventSequence === "number" | 20 | const currentSequence = task.nextEventSequence ?? 0; |
| 21 | ? (task as { nextEventSequence: number }).nextEventSequence | ||
| 22 | : 0; | ||
| 23 | const sequence = currentSequence + 1; | 21 | const sequence = currentSequence + 1; |
| 24 | const createdAt = Date.now(); | 22 | const createdAt = Date.now(); |
| 25 | 23 | ||
| @@ -0,0 +1,51 @@ | |||
| 1 | import { expect, test } from "bun:test"; | ||
| 2 | import { normalizeExternalToolSource } from "../runtime/tool_source_loading"; | ||
| 3 | import { normalizeToolSourceConfig } from "./tool_source_config"; | ||
| 4 | |||
| 5 | test("normalizeToolSourceConfig returns an error result for invalid mcp config", () => { | ||
| 6 | const result = normalizeToolSourceConfig("mcp", {}); | ||
| 7 | |||
| 8 | expect(result.isErr()).toBe(true); | ||
| 9 | if (result.isErr()) { | ||
| 10 | expect(result.error.message).toBe("Tool source url is required"); | ||
| 11 | } | ||
| 12 | }); | ||
| 13 | |||
| 14 | test("normalizeToolSourceConfig trims and normalizes openapi auth/header values", () => { | ||
| 15 | const result = normalizeToolSourceConfig("openapi", { | ||
| 16 | spec: " https://example.com/openapi.json ", | ||
| 17 | baseUrl: " https://api.example.com ", | ||
| 18 | auth: { | ||
| 19 | type: "apiKey", | ||
| 20 | header: " x-api-key ", | ||
| 21 | value: "token-value", | ||
| 22 | }, | ||
| 23 | }); | ||
| 24 | |||
| 25 | expect(result.isOk()).toBe(true); | ||
| 26 | if (result.isOk()) { | ||
| 27 | expect(result.value.spec).toBe("https://example.com/openapi.json"); | ||
| 28 | expect(result.value.baseUrl).toBe("https://api.example.com"); | ||
| 29 | expect(result.value.auth?.type).toBe("apiKey"); | ||
| 30 | if (result.value.auth?.type === "apiKey") { | ||
| 31 | expect(result.value.auth.header).toBe("x-api-key"); | ||
| 32 | expect(result.value.auth.value).toBe("token-value"); | ||
| 33 | } | ||
| 34 | } | ||
| 35 | }); | ||
| 36 | |||
| 37 | test("normalizeExternalToolSource returns error result with source context", () => { | ||
| 38 | const result = normalizeExternalToolSource({ | ||
| 39 | id: "src_123", | ||
| 40 | type: "graphql", | ||
| 41 | name: "bad-graphql", | ||
| 42 | config: {}, | ||
| 43 | }); | ||
| 44 | |||
| 45 | expect(result.isErr()).toBe(true); | ||
| 46 | if (result.isErr()) { | ||
| 47 | expect(result.error.message).toBe( | ||
| 48 | "Failed to normalize 'bad-graphql' source config: Tool source endpoint is required", | ||
| 49 | ); | ||
| 50 | } | ||
| 51 | }); | ||
| @@ -1,9 +1,34 @@ | |||
| 1 | import { Result } from "better-result"; | 1 | import { Result } from "better-result"; |
| 2 | import type { OpenApiAuth } from "../../../core/src/tool/source-types"; | 2 | import type { |
| 3 | GraphqlToolSourceConfig, | ||
| 4 | McpToolSourceConfig, | ||
| 5 | OpenApiAuth, | ||
| 6 | OpenApiToolSourceConfig, | ||
| 7 | } from "../../../core/src/tool/source-types"; | ||
| 3 | import type { ToolApprovalMode } from "../../../core/src/types"; | 8 | import type { ToolApprovalMode } from "../../../core/src/types"; |
| 4 | import { asRecord } from "../lib/object"; | 9 | import { asRecord } from "../lib/object"; |
| 5 | 10 | ||
| 6 | type ToolSourceType = "mcp" | "openapi" | "graphql"; | 11 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; |
| 12 | |||
| 13 | export type NormalizedMcpToolSourceConfig = Pick< | ||
| 14 | McpToolSourceConfig, | ||
| 15 | "url" | "auth" | "discoveryHeaders" | "transport" | "queryParams" | "defaultApproval" | "overrides" | ||
| 16 | >; | ||
| 17 | |||
| 18 | export type NormalizedGraphqlToolSourceConfig = Pick< | ||
| 19 | GraphqlToolSourceConfig, | ||
| 20 | "endpoint" | "schema" | "auth" | "defaultQueryApproval" | "defaultMutationApproval" | "overrides" | ||
| 21 | >; | ||
| 22 | |||
| 23 | export type NormalizedOpenApiToolSourceConfig = Pick< | ||
| 24 | OpenApiToolSourceConfig, | ||
| 25 | "spec" | "collectionUrl" | "postmanProxyUrl" | "baseUrl" | "auth" | "defaultReadApproval" | "defaultWriteApproval" | "overrides" | ||
| 26 | >; | ||
| 27 | |||
| 28 | export type NormalizedToolSourceConfig = | ||
| 29 | | NormalizedMcpToolSourceConfig | ||
| 30 | | NormalizedGraphqlToolSourceConfig | ||
| 31 | | NormalizedOpenApiToolSourceConfig; | ||
| 7 | 32 | ||
| 8 | function optionalTrimmedString(value: unknown): string | undefined { | 33 | function optionalTrimmedString(value: unknown): string | undefined { |
| 9 | if (typeof value !== "string") return undefined; | 34 | if (typeof value !== "string") return undefined; |
| @@ -164,10 +189,26 @@ function normalizeSpec( · 154 unmodified lines | |||
| 164 | return Result.ok(specObject); | 189 | return Result.ok(specObject); |
| 165 | } | 190 | } |
| 166 | 191 | ||
| 192 | export function normalizeToolSourceConfig( | ||
| 193 | type: "mcp", | ||
| 194 | rawConfig: unknown, | ||
| 195 | ): Result<NormalizedMcpToolSourceConfig, Error>; | ||
| 196 | export function normalizeToolSourceConfig( | ||
| 197 | type: "graphql", | ||
| 198 | rawConfig: unknown, | ||
| 199 | ): Result<NormalizedGraphqlToolSourceConfig, Error>; | ||
| 200 | export function normalizeToolSourceConfig( | ||
| 201 | type: "openapi", | ||
| 202 | rawConfig: unknown, | ||
| 203 | ): Result<NormalizedOpenApiToolSourceConfig, Error>; | ||
| 204 | export function normalizeToolSourceConfig( | ||
| 205 | type: ToolSourceType, | ||
| 206 | rawConfig: unknown, | ||
| 207 | ): Result<NormalizedToolSourceConfig, Error>; | ||
| 167 | export function normalizeToolSourceConfig( | 208 | export function normalizeToolSourceConfig( |
| 168 | type: ToolSourceType, | 209 | type: ToolSourceType, |
| 169 | rawConfig: unknown, | 210 | rawConfig: unknown, |
| 170 | ): Result<Record<string, unknown>, Error> { | 211 | ): Result<NormalizedToolSourceConfig, Error> { |
| 171 | const config = asRecord(rawConfig); | 212 | const config = asRecord(rawConfig); |
| 172 | 213 | ||
| 173 | if (type === "mcp") { | 214 | if (type === "mcp") { |
| @@ -4,7 +4,7 @@ import type { ActionCtx } from "../../convex/_generated/server"; · 3 unmodified lines | |||
| 4 | import { internal } from "../../convex/_generated/api"; | 4 | import { internal } from "../../convex/_generated/api"; |
| 5 | import { resolveCredentialPayload } from "../../../core/src/credential-providers"; | 5 | import { resolveCredentialPayload } from "../../../core/src/credential-providers"; |
| 6 | import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants"; | 6 | import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants"; |
| 7 | import type { CredentialScope, ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types"; | 7 | import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types"; |
| 8 | import { asPayload } from "../lib/object"; | 8 | import { asPayload } from "../lib/object"; |
| 9 | 9 | ||
| 10 | export async function resolveCredentialHeaders( | 10 | export async function resolveCredentialHeaders( |
| @@ -15,7 +15,7 @@ export async function resolveCredentialHeaders( · 4 unmodified lines | |||
| 15 | const record = await ctx.runQuery(internal.database.resolveCredential, { | 15 | const record = await ctx.runQuery(internal.database.resolveCredential, { |
| 16 | workspaceId: task.workspaceId, | 16 | workspaceId: task.workspaceId, |
| 17 | sourceKey: spec.sourceKey, | 17 | sourceKey: spec.sourceKey, |
| 18 | scope: spec.mode as CredentialScope, | 18 | scope: spec.mode, |
| 19 | actorId: task.actorId, | 19 | actorId: task.actorId, |
| 20 | }); | 20 | }); |
| 21 | 21 | ||
| @@ -25,25 +25,26 @@ export async function resolveCredentialHeaders( · 3 unmodified lines | |||
| 25 | if (!source) { | 25 | if (!source) { |
| 26 | return null; | 26 | return null; |
| 27 | } | 27 | } |
| 28 | const sourcePayload = asPayload(source); | ||
| 28 | 29 | ||
| 29 | const headers: Record<string, string> = {}; | 30 | const headers: Record<string, string> = {}; |
| 30 | if (spec.authType === "bearer") { | 31 | if (spec.authType === "bearer") { |
| 31 | const token = String((source as Record<string, unknown>).token ?? "").trim(); | 32 | const token = String(sourcePayload.token ?? "").trim(); |
| 32 | if (token) headers.authorization = `Bearer ${token}`; | 33 | if (token) headers.authorization = `Bearer ${token}`; |
| 33 | } else if (spec.authType === "apiKey") { | 34 | } else if (spec.authType === "apiKey") { |
| 34 | const headerName = spec.headerName ?? String((source as Record<string, unknown>).headerName ?? "x-api-key"); | 35 | const headerName = spec.headerName ?? String(sourcePayload.headerName ?? "x-api-key"); |
| 35 | const value = String((source as Record<string, unknown>).value ?? (source as Record<string, unknown>).token ?? "").trim(); | 36 | const value = String(sourcePayload.value ?? sourcePayload.token ?? "").trim(); |
| 36 | if (value) headers[headerName] = value; | 37 | if (value) headers[headerName] = value; |
| 37 | } else if (spec.authType === "basic") { | 38 | } else if (spec.authType === "basic") { |
| 38 | const username = String((source as Record<string, unknown>).username ?? ""); | 39 | const username = String(sourcePayload.username ?? ""); |
| 39 | const password = String((source as Record<string, unknown>).password ?? ""); | 40 | const password = String(sourcePayload.password ?? ""); |
| 40 | if (username || password) { | 41 | if (username || password) { |
| 41 | const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); | 42 | const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); |
| 42 | headers.authorization = `Basic ${encoded}`; | 43 | headers.authorization = `Basic ${encoded}`; |
| 43 | } | 44 | } |
| 44 | } | 45 | } |
| 45 | 46 | ||
| 46 | const bindingOverrides = asPayload((record?.overridesJson as unknown) ?? {}); | 47 | const bindingOverrides = asPayload(record?.overridesJson ?? {}); |
| 47 | const overrideHeaders = asPayload(bindingOverrides.headers); | 48 | const overrideHeaders = asPayload(bindingOverrides.headers); |
| 48 | for (const [key, value] of Object.entries(overrideHeaders)) { | 49 | for (const [key, value] of Object.entries(overrideHeaders)) { |
| 49 | if (!key) continue; | 50 | if (!key) continue; |
| @@ -1,10 +1,12 @@ | |||
| 1 | "use node"; | 1 | "use node"; |
| 2 | 2 | ||
| 3 | import { Result } from "better-result"; | ||
| 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 { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths"; | 6 | import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths"; |
| 6 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; | 7 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; |
| 7 | import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization"; | 8 | import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization"; |
| 9 | import { asPayload } from "../lib/object"; | ||
| 8 | import { getDecisionForContext, getToolDecision } from "./policy"; | 10 | import { getDecisionForContext, getToolDecision } from "./policy"; |
| 9 | import { getReadyRegistryBuildId } from "./tool_registry_state"; | 11 | import { getReadyRegistryBuildId } from "./tool_registry_state"; |
| 10 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; | 12 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; |
| @@ -18,7 +20,7 @@ export function getGraphqlDecision( · 7 unmodified lines | |||
| 18 | policies: AccessPolicyRecord[], | 20 | policies: AccessPolicyRecord[], |
| 19 | ): { decision: PolicyDecision; effectivePaths: string[] } { | 21 | ): { decision: PolicyDecision; effectivePaths: string[] } { |
| 20 | const sourceName = tool._graphqlSource!; | 22 | const sourceName = tool._graphqlSource!; |
| 21 | const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {}; | 23 | const payload = asPayload(input); |
| 22 | const queryString = typeof payload.query === "string" ? payload.query : ""; | 24 | const queryString = typeof payload.query === "string" ? payload.query : ""; |
| 23 | 25 | ||
| 24 | if (!queryString.trim()) { | 26 | if (!queryString.trim()) { |
| @@ -95,13 +97,13 @@ export async function resolveToolForCall( · 70 unmodified lines | |||
| 95 | ctx: ActionCtx, | 97 | ctx: ActionCtx, |
| 96 | task: TaskRecord, | 98 | task: TaskRecord, |
| 97 | toolPath: string, | 99 | toolPath: string, |
| 98 | ): Promise<{ | 100 | ): Promise<Result<{ |
| 99 | tool: ToolDefinition; | 101 | tool: ToolDefinition; |
| 100 | resolvedToolPath: string; | 102 | resolvedToolPath: string; |
| 101 | }> { | 103 | }, Error>> { |
| 102 | const builtin = baseTools.get(toolPath); | 104 | const builtin = baseTools.get(toolPath); |
| 103 | if (builtin) { | 105 | if (builtin) { |
| 104 | return { tool: builtin, resolvedToolPath: toolPath }; | 106 | return Result.ok({ tool: builtin, resolvedToolPath: toolPath }); |
| 105 | } | 107 | } |
| 106 | 108 | ||
| 107 | const buildId = await getReadyRegistryBuildId(ctx, { | 109 | const buildId = await getReadyRegistryBuildId(ctx, { |
| @@ -137,14 +139,20 @@ export async function resolveToolForCall( · 29 unmodified lines | |||
| 137 | 139 | ||
| 138 | if (!entry) { | 140 | if (!entry) { |
| 139 | const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath); | 141 | const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath); |
| 140 | throw new Error(unknownToolErrorMessage(toolPath, suggestions)); | 142 | return Result.err(new Error(unknownToolErrorMessage(toolPath, suggestions))); |
| 141 | } | 143 | } |
| 142 | 144 | ||
| 143 | const serialized = JSON.parse(entry.serializedToolJson) as SerializedTool; | 145 | const serializedResult = Result.try(() => JSON.parse(entry.serializedToolJson) as SerializedTool); |
| 146 | if (serializedResult.isErr()) { | ||
| 147 | return Result.err( | ||
| 148 | new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serializedResult.error.message}`), | ||
| 149 | ); | ||
| 150 | } | ||
| 151 | const serialized = serializedResult.value; | ||
| 144 | const [tool] = rehydrateTools([serialized], baseTools); | 152 | const [tool] = rehydrateTools([serialized], baseTools); |
| 145 | if (!tool) { | 153 | if (!tool) { |
| 146 | throw new Error(`Failed to rehydrate tool: ${resolvedToolPath}`); | 154 | return Result.err(new Error(`Failed to rehydrate tool: ${resolvedToolPath}`)); |
| 147 | } | 155 | } |
| 148 | 156 | ||
| 149 | return { tool, resolvedToolPath }; | 157 | return Result.ok({ tool, resolvedToolPath }); |
| 150 | } | 158 | } |
| @@ -2,7 +2,10 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import type { ActionCtx } from "../../convex/_generated/server"; | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | import { internal } from "../../convex/_generated/api"; | 4 | import { internal } from "../../convex/_generated/api"; |
| 5 | import { APPROVAL_PENDING_PREFIX } from "../../../core/src/execution-constants"; | 5 | import { |
| 6 | APPROVAL_DENIED_PREFIX, | ||
| 7 | APPROVAL_PENDING_PREFIX, | ||
| 8 | } from "../../../core/src/execution-constants"; | ||
| 6 | import type { | 9 | import type { |
| 7 | AccessPolicyRecord, | 10 | AccessPolicyRecord, |
| 8 | PolicyDecision, | 11 | PolicyDecision, |
| @@ -66,237 +69,265 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 57 unmodified lines | |||
| 66 | })) as ToolCallRecord; | 69 | })) as ToolCallRecord; |
| 67 | assertPersistedCallRunnable(persistedCall, callId); | 70 | assertPersistedCallRunnable(persistedCall, callId); |
| 68 | 71 | ||
| 69 | const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId }); | 72 | let effectiveToolPath = toolPath; |
| 70 | const typedPolicies = policies as AccessPolicyRecord[]; | 73 | try { |
| 71 | 74 | const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId }); | |
| 72 | // Fast system tools are handled server-side from the registry. | 75 | const typedPolicies = policies as AccessPolicyRecord[]; |
| 73 | if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") { | 76 | const finalizeImmediateTool = async (value: unknown): Promise<unknown> => { |
| 74 | const toolRegistry = internal.toolRegistry; | 77 | if (persistedCall.status === "requested") { |
| 75 | const buildId = await getReadyRegistryBuildId(ctx, { | 78 | await publishTaskEvent(ctx, task.id, "task", "tool.call.started", { |
| 76 | workspaceId: task.workspaceId, | 79 | taskId: task.id, |
| 77 | actorId: task.actorId, | 80 | callId, |
| 78 | clientId: task.clientId, | 81 | toolPath, |
| 79 | refreshOnStale: true, | 82 | approval: "auto", |
| 80 | }); | 83 | }); |
| 81 | 84 | } | |
| 82 | const payload = typeof input === "string" | 85 | await completeToolCall(ctx, { |
| 83 | ? { query: input } | 86 | taskId: task.id, |
| 84 | : (input && typeof input === "object" ? (input as Record<string, unknown>) : {}); | 87 | callId, |
| 85 | const isAllowed = (path: string, approval: ToolDefinition["approval"]) => | 88 | toolPath, |
| 86 | getDecisionForContext( | 89 | }); |
| 87 | { path, approval, description: "", run: async () => null } as ToolDefinition, | 90 | return value; |
| 88 | { workspaceId: task.workspaceId, actorId: task.actorId, clientId: task.clientId }, | ||
| 89 | typedPolicies, | ||
| 90 | ) !== "deny"; | ||
| 91 | |||
| 92 | const normalizeHint = (value: unknown, fallback: string) => { | ||
| 93 | const str = typeof value === "string" ? value.trim() : ""; | ||
| 94 | return str.length > 0 ? str : fallback; | ||
| 95 | }; | 91 | }; |
| 96 | 92 | ||
| 97 | if (toolPath === "catalog.namespaces") { | 93 | // Fast system tools are handled server-side from the registry. |
| 98 | const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200))); | 94 | if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") { |
| 99 | const namespaces = await ctx.runQuery(toolRegistry.listNamespaces, { | 95 | const toolRegistry = internal.toolRegistry; |
| 96 | const buildId = await getReadyRegistryBuildId(ctx, { | ||
| 100 | workspaceId: task.workspaceId, | 97 | workspaceId: task.workspaceId, |
| 101 | buildId, | 98 | actorId: task.actorId, |
| 102 | limit, | 99 | clientId: task.clientId, |
| 103 | }) as Array<{ namespace: string; toolCount: number; samplePaths: string[] }>; | 100 | refreshOnStale: true, |
| 104 | return { namespaces, total: namespaces.length }; | 101 | }); |
| 105 | } | ||
| 106 | 102 | ||
| 107 | if (toolPath === "catalog.tools") { | 103 | const payload = typeof input === "string" |
| 108 | const namespace = String(payload.namespace ?? "").trim().toLowerCase(); | 104 | ? { query: input } |
| 109 | const query = String(payload.query ?? "").trim(); | 105 | : asPayload(input); |
| 110 | const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50))); | 106 | const isAllowed = (path: string, approval: ToolDefinition["approval"]) => { |
| 111 | 107 | const policyProbeTool: ToolDefinition = { | |
| 112 | const raw = query | 108 | path, |
| 113 | ? await ctx.runQuery(toolRegistry.searchTools, { | 109 | approval, |
| 114 | workspaceId: task.workspaceId, | 110 | description: "", |
| 115 | buildId, | 111 | run: async () => null, |
| 116 | query, | 112 | }; |
| 117 | limit, | 113 | return getDecisionForContext( |
| 118 | }) | 114 | policyProbeTool, |
| 119 | : namespace | 115 | { workspaceId: task.workspaceId, actorId: task.actorId, clientId: task.clientId }, |
| 120 | ? await ctx.runQuery(toolRegistry.listToolsByNamespace, { | 116 | typedPolicies, |
| 117 | ) !== "deny"; | ||
| 118 | }; | ||
| 119 | |||
| 120 | const normalizeHint = (value: unknown, fallback: string) => { | ||
| 121 | const str = typeof value === "string" ? value.trim() : ""; | ||
| 122 | return str.length > 0 ? str : fallback; | ||
| 123 | }; | ||
| 124 | |||
| 125 | if (toolPath === "catalog.namespaces") { | ||
| 126 | const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200))); | ||
| 127 | const namespaces = await ctx.runQuery(toolRegistry.listNamespaces, { | ||
| 128 | workspaceId: task.workspaceId, | ||
| 129 | buildId, | ||
| 130 | limit, | ||
| 131 | }) as Array<{ namespace: string; toolCount: number; samplePaths: string[] }>; | ||
| 132 | return await finalizeImmediateTool({ namespaces, total: namespaces.length }); | ||
| 133 | } | ||
| 134 | |||
| 135 | if (toolPath === "catalog.tools") { | ||
| 136 | const namespace = String(payload.namespace ?? "").trim().toLowerCase(); | ||
| 137 | const query = String(payload.query ?? "").trim(); | ||
| 138 | const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50))); | ||
| 139 | |||
| 140 | const raw = query | ||
| 141 | ? await ctx.runQuery(toolRegistry.searchTools, { | ||
| 121 | workspaceId: task.workspaceId, | 142 | workspaceId: task.workspaceId, |
| 122 | buildId, | 143 | buildId, |
| 123 | namespace, | 144 | query, |
| 124 | limit, | 145 | limit, |
| 125 | }) | 146 | }) |
| 126 | : []; | 147 | : namespace |
| 148 | ? await ctx.runQuery(toolRegistry.listToolsByNamespace, { | ||
| 149 | workspaceId: task.workspaceId, | ||
| 150 | buildId, | ||
| 151 | namespace, | ||
| 152 | limit, | ||
| 153 | }) | ||
| 154 | : []; | ||
| 155 | |||
| 156 | const results = (raw as RegistryToolEntry[]) | ||
| 157 | .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`)) | ||
| 158 | .filter((entry) => isAllowed(entry.path, entry.approval)) | ||
| 159 | .slice(0, limit) | ||
| 160 | .map((entry) => { | ||
| 161 | const preferredPath = entry.preferredPath ?? entry.path; | ||
| 162 | return { | ||
| 163 | path: preferredPath, | ||
| 164 | source: entry.source, | ||
| 165 | approval: entry.approval, | ||
| 166 | description: entry.description, | ||
| 167 | input: normalizeHint(entry.displayInput, "{}"), | ||
| 168 | output: normalizeHint(entry.displayOutput, "unknown"), | ||
| 169 | // required keys are encoded in the `input` type hint | ||
| 170 | }; | ||
| 171 | }); | ||
| 172 | |||
| 173 | return await finalizeImmediateTool({ results, total: results.length }); | ||
| 174 | } | ||
| 175 | |||
| 176 | // discover | ||
| 177 | const query = String(payload.query ?? "").trim(); | ||
| 178 | const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8))); | ||
| 179 | const compact = payload.compact === false ? false : true; | ||
| 180 | const hits = await ctx.runQuery(toolRegistry.searchTools, { | ||
| 181 | workspaceId: task.workspaceId, | ||
| 182 | buildId, | ||
| 183 | query, | ||
| 184 | limit: Math.max(limit * 2, limit), | ||
| 185 | }) as RegistryToolEntry[]; | ||
| 127 | 186 | ||
| 128 | const results = (raw as RegistryToolEntry[]) | 187 | const filtered = hits |
| 129 | .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`)) | ||
| 130 | .filter((entry) => isAllowed(entry.path, entry.approval)) | 188 | .filter((entry) => isAllowed(entry.path, entry.approval)) |
| 131 | .slice(0, limit) | 189 | .slice(0, limit); |
| 132 | .map((entry) => { | 190 | |
| 133 | const preferredPath = entry.preferredPath ?? entry.path; | 191 | const results = filtered.map((entry) => { |
| 134 | return { | 192 | const preferredPath = entry.preferredPath ?? entry.path; |
| 135 | path: preferredPath, | 193 | const description = compact ? String(entry.description ?? "").split("\n")[0] : entry.description; |
| 136 | source: entry.source, | 194 | return { |
| 137 | approval: entry.approval, | 195 | path: preferredPath, |
| 138 | description: entry.description, | 196 | source: entry.source, |
| 139 | input: normalizeHint(entry.displayInput, "{}"), | 197 | approval: entry.approval, |
| 140 | output: normalizeHint(entry.displayOutput, "unknown"), | 198 | description, |
| 141 | // required keys are encoded in the `input` type hint | 199 | input: normalizeHint(entry.displayInput, "{}"), |
| 142 | }; | 200 | output: normalizeHint(entry.displayOutput, "unknown"), |
| 143 | }); | 201 | // required keys are encoded in the `input` type hint |
| 144 | 202 | }; | |
| 145 | return { results, total: results.length }; | 203 | }); |
| 146 | } | ||
| 147 | |||
| 148 | // discover | ||
| 149 | const query = String(payload.query ?? "").trim(); | ||
| 150 | const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8))); | ||
| 151 | const compact = payload.compact === false ? false : true; | ||
| 152 | const hits = await ctx.runQuery(toolRegistry.searchTools, { | ||
| 153 | workspaceId: task.workspaceId, | ||
| 154 | buildId, | ||
| 155 | query, | ||
| 156 | limit: Math.max(limit * 2, limit), | ||
| 157 | }) as RegistryToolEntry[]; | ||
| 158 | |||
| 159 | const filtered = hits | ||
| 160 | .filter((entry) => isAllowed(entry.path, entry.approval)) | ||
| 161 | .slice(0, limit); | ||
| 162 | |||
| 163 | const results = filtered.map((entry) => { | ||
| 164 | const preferredPath = entry.preferredPath ?? entry.path; | ||
| 165 | const description = compact ? String(entry.description ?? "").split("\n")[0] : entry.description; | ||
| 166 | return { | ||
| 167 | path: preferredPath, | ||
| 168 | source: entry.source, | ||
| 169 | approval: entry.approval, | ||
| 170 | description, | ||
| 171 | input: normalizeHint(entry.displayInput, "{}"), | ||
| 172 | output: normalizeHint(entry.displayOutput, "unknown"), | ||
| 173 | // required keys are encoded in the `input` type hint | ||
| 174 | }; | ||
| 175 | }); | ||
| 176 | |||
| 177 | const bestPath = results[0]?.path ?? null; | ||
| 178 | return { | ||
| 179 | bestPath, | ||
| 180 | results, | ||
| 181 | total: results.length, | ||
| 182 | }; | ||
| 183 | } | ||
| 184 | |||
| 185 | let { tool, resolvedToolPath } = await resolveToolForCall(ctx, task, toolPath); | ||
| 186 | 204 | ||
| 187 | let decision: PolicyDecision; | 205 | const bestPath = results[0]?.path ?? null; |
| 188 | let effectiveToolPath = resolvedToolPath; | 206 | return await finalizeImmediateTool({ |
| 189 | if (tool._graphqlSource) { | 207 | bestPath, |
| 190 | const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies); | 208 | results, |
| 191 | decision = result.decision; | 209 | total: results.length, |
| 192 | if (result.effectivePaths.length > 0) { | 210 | }); |
| 193 | effectiveToolPath = result.effectivePaths.join(", "); | ||
| 194 | } | 211 | } |
| 195 | } else { | ||
| 196 | decision = getToolDecision(task, tool, typedPolicies); | ||
| 197 | } | ||
| 198 | |||
| 199 | const publishToolStarted = persistedCall.status === "requested"; | ||
| 200 | 212 | ||
| 201 | if (decision === "deny") { | 213 | const resolvedToolResult = await resolveToolForCall(ctx, task, toolPath); |
| 202 | const deniedMessage = `${effectiveToolPath} (policy denied)`; | 214 | if (resolvedToolResult.isErr()) { |
| 203 | await denyToolCall(ctx, { | 215 | throw resolvedToolResult.error; |
| 204 | task, | ||
| 205 | callId, | ||
| 206 | toolPath: effectiveToolPath, | ||
| 207 | deniedMessage, | ||
| 208 | reason: "policy_deny", | ||
| 209 | }); | ||
| 210 | } | ||
| 211 | |||
| 212 | let credential: ResolvedToolCredential | undefined; | ||
| 213 | if (tool.credential) { | ||
| 214 | const resolved = await resolveCredentialHeaders(ctx, tool.credential, task); | ||
| 215 | if (!resolved) { | ||
| 216 | throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`); | ||
| 217 | } | 216 | } |
| 218 | credential = resolved; | 217 | const { tool, resolvedToolPath } = resolvedToolResult.value; |
| 219 | } | 218 | |
| 220 | 219 | let decision: PolicyDecision; | |
| 221 | if (publishToolStarted) { | 220 | effectiveToolPath = resolvedToolPath; |
| 222 | await publishTaskEvent(ctx, task.id, "task", "tool.call.started", { | 221 | if (tool._graphqlSource) { |
| 223 | taskId: task.id, | 222 | const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies); |
| 224 | callId, | 223 | decision = result.decision; |
| 225 | toolPath: effectiveToolPath, | 224 | if (result.effectivePaths.length > 0) { |
| 226 | approval: decision === "require_approval" ? "required" : "auto", | 225 | effectiveToolPath = result.effectivePaths.join(", "); |
| 227 | }); | 226 | } |
| 228 | } | 227 | } else { |
| 229 | 228 | decision = getToolDecision(task, tool, typedPolicies); | |
| 230 | let approvalSatisfied = false; | ||
| 231 | if (persistedCall.approvalId) { | ||
| 232 | const existingApproval = await ctx.runQuery(internal.database.getApproval, { | ||
| 233 | approvalId: persistedCall.approvalId, | ||
| 234 | }); | ||
| 235 | if (!existingApproval) { | ||
| 236 | throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`); | ||
| 237 | } | 229 | } |
| 238 | 230 | ||
| 239 | if (existingApproval.status === "pending") { | 231 | const publishToolStarted = persistedCall.status === "requested"; |
| 240 | throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`); | ||
| 241 | } | ||
| 242 | 232 | ||
| 243 | if (existingApproval.status === "denied") { | 233 | if (decision === "deny") { |
| 244 | await denyToolCallForApproval(ctx, { | 234 | const deniedMessage = `${effectiveToolPath} (policy denied)`; |
| 235 | await denyToolCall(ctx, { | ||
| 245 | task, | 236 | task, |
| 246 | callId, | 237 | callId, |
| 247 | toolPath: effectiveToolPath, | 238 | toolPath: effectiveToolPath, |
| 248 | approvalId: existingApproval.id, | 239 | deniedMessage, |
| 240 | reason: "policy_deny", | ||
| 249 | }); | 241 | }); |
| 250 | } | 242 | } |
| 251 | 243 | ||
| 252 | approvalSatisfied = existingApproval.status === "approved"; | 244 | let credential: ResolvedToolCredential | undefined; |
| 253 | } | 245 | if (tool.credential) { |
| 254 | 246 | const resolved = await resolveCredentialHeaders(ctx, tool.credential, task); | |
| 255 | if (decision === "require_approval" && !approvalSatisfied) { | 247 | if (!resolved) { |
| 256 | const approvalId = persistedCall.approvalId ?? createApprovalId(); | 248 | throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`); |
| 257 | let approval = await ctx.runQuery(internal.database.getApproval, { | 249 | } |
| 258 | approvalId, | 250 | credential = resolved; |
| 259 | }); | 251 | } |
| 260 | 252 | ||
| 261 | if (!approval) { | 253 | if (publishToolStarted) { |
| 262 | approval = await ctx.runMutation(internal.database.createApproval, { | 254 | await publishTaskEvent(ctx, task.id, "task", "tool.call.started", { |
| 263 | id: approvalId, | ||
| 264 | taskId: task.id, | 255 | taskId: task.id, |
| 256 | callId, | ||
| 265 | toolPath: effectiveToolPath, | 257 | toolPath: effectiveToolPath, |
| 266 | input, | 258 | approval: decision === "require_approval" ? "required" : "auto", |
| 267 | }); | 259 | }); |
| 260 | } | ||
| 268 | 261 | ||
| 269 | await publishTaskEvent(ctx, task.id, "approval", "approval.requested", { | 262 | let approvalSatisfied = false; |
| 270 | approvalId: approval.id, | 263 | if (persistedCall.approvalId) { |
| 271 | taskId: task.id, | 264 | const existingApproval = await ctx.runQuery(internal.database.getApproval, { |
| 272 | callId, | 265 | approvalId: persistedCall.approvalId, |
| 273 | toolPath: approval.toolPath, | ||
| 274 | input: asPayload(approval.input), | ||
| 275 | createdAt: approval.createdAt, | ||
| 276 | }); | 266 | }); |
| 267 | if (!existingApproval) { | ||
| 268 | throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`); | ||
| 269 | } | ||
| 270 | |||
| 271 | if (existingApproval.status === "pending") { | ||
| 272 | throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`); | ||
| 273 | } | ||
| 274 | |||
| 275 | if (existingApproval.status === "denied") { | ||
| 276 | await denyToolCallForApproval(ctx, { | ||
| 277 | task, | ||
| 278 | callId, | ||
| 279 | toolPath: effectiveToolPath, | ||
| 280 | approvalId: existingApproval.id, | ||
| 281 | }); | ||
| 282 | } | ||
| 283 | |||
| 284 | approvalSatisfied = existingApproval.status === "approved"; | ||
| 277 | } | 285 | } |
| 278 | 286 | ||
| 279 | await ctx.runMutation(internal.database.setToolCallPendingApproval, { | 287 | if (decision === "require_approval" && !approvalSatisfied) { |
| 280 | taskId: task.id, | 288 | const approvalId = persistedCall.approvalId ?? createApprovalId(); |
| 281 | callId, | 289 | let approval = await ctx.runQuery(internal.database.getApproval, { |
| 282 | approvalId: approval.id, | 290 | approvalId, |
| 283 | }); | 291 | }); |
| 284 | 292 | ||
| 285 | if (approval.status === "pending") { | 293 | if (!approval) { |
| 286 | throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`); | 294 | approval = await ctx.runMutation(internal.database.createApproval, { |
| 287 | } | 295 | id: approvalId, |
| 296 | taskId: task.id, | ||
| 297 | toolPath: effectiveToolPath, | ||
| 298 | input, | ||
| 299 | }); | ||
| 288 | 300 | ||
| 289 | if (approval.status === "denied") { | 301 | await publishTaskEvent(ctx, task.id, "approval", "approval.requested", { |
| 290 | await denyToolCallForApproval(ctx, { | 302 | approvalId: approval.id, |
| 291 | task, | 303 | taskId: task.id, |
| 304 | callId, | ||
| 305 | toolPath: approval.toolPath, | ||
| 306 | input: asPayload(approval.input), | ||
| 307 | createdAt: approval.createdAt, | ||
| 308 | }); | ||
| 309 | } | ||
| 310 | |||
| 311 | await ctx.runMutation(internal.database.setToolCallPendingApproval, { | ||
| 312 | taskId: task.id, | ||
| 292 | callId, | 313 | callId, |
| 293 | toolPath: effectiveToolPath, | ||
| 294 | approvalId: approval.id, | 314 | approvalId: approval.id, |
| 295 | }); | 315 | }); |
| 316 | |||
| 317 | if (approval.status === "pending") { | ||
| 318 | throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`); | ||
| 319 | } | ||
| 320 | |||
| 321 | if (approval.status === "denied") { | ||
| 322 | await denyToolCallForApproval(ctx, { | ||
| 323 | task, | ||
| 324 | callId, | ||
| 325 | toolPath: effectiveToolPath, | ||
| 326 | approvalId: approval.id, | ||
| 327 | }); | ||
| 328 | } | ||
| 296 | } | 329 | } |
| 297 | } | ||
| 298 | 330 | ||
| 299 | try { | ||
| 300 | const context: ToolRunContext = { | 331 | const context: ToolRunContext = { |
| 301 | taskId: task.id, | 332 | taskId: task.id, |
| 302 | workspaceId: task.workspaceId, | 333 | workspaceId: task.workspaceId, |
| @@ -315,12 +346,19 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 12 unmodified lines | |||
| 315 | return value; | 346 | return value; |
| 316 | } catch (error) { | 347 | } catch (error) { |
| 317 | const message = describeError(error); | 348 | const message = describeError(error); |
| 318 | await failToolCall(ctx, { | 349 | const isApprovalControlSignal = |
| 319 | taskId: task.id, | 350 | message.startsWith(APPROVAL_PENDING_PREFIX) || |
| 320 | callId, | 351 | message.startsWith(APPROVAL_DENIED_PREFIX); |
| 321 | error: message, | 352 | |
| 322 | toolPath: effectiveToolPath, | 353 | if (!isApprovalControlSignal) { |
| 323 | }); | 354 | await failToolCall(ctx, { |
| 355 | taskId: task.id, | ||
| 356 | callId, | ||
| 357 | error: message, | ||
| 358 | toolPath: effectiveToolPath, | ||
| 359 | }); | ||
| 360 | } | ||
| 361 | |||
| 324 | throw error; | 362 | throw error; |
| 325 | } | 363 | } |
| 326 | } | 364 | } |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | |||
| 3 | import type { ActionCtx } from "../../convex/_generated/server"; | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; | 4 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| 5 | import { internal } from "../../convex/_generated/api"; | 5 | import { internal } from "../../convex/_generated/api"; |
| 6 | import { asPayload } from "../lib/object"; | ||
| 6 | import { sourceSignature } from "./tool_source_loading"; | 7 | import { sourceSignature } from "./tool_source_loading"; |
| 7 | 8 | ||
| 8 | export const TOOL_REGISTRY_SIGNATURE_PREFIX = "toolreg_v2|"; | 9 | export const TOOL_REGISTRY_SIGNATURE_PREFIX = "toolreg_v2|"; |
| @@ -26,14 +27,52 @@ type ToolSourceState = { · 17 unmodified lines | |||
| 26 | enabled: boolean; | 27 | enabled: boolean; |
| 27 | }; | 28 | }; |
| 28 | 29 | ||
| 30 | function toRegistryState(value: unknown): RegistryState { | ||
| 31 | if (!value || typeof value !== "object" || Array.isArray(value)) { | ||
| 32 | return null; | ||
| 33 | } | ||
| 34 | |||
| 35 | const state = asPayload(value); | ||
| 36 | if (typeof state.signature !== "string") { | ||
| 37 | return null; | ||
| 38 | } | ||
| 39 | |||
| 40 | return { | ||
| 41 | signature: state.signature, | ||
| 42 | readyBuildId: typeof state.readyBuildId === "string" ? state.readyBuildId : undefined, | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | |||
| 46 | function toToolSourceStateList(value: unknown): ToolSourceState[] { | ||
| 47 | if (!Array.isArray(value)) { | ||
| 48 | return []; | ||
| 49 | } | ||
| 50 | |||
| 51 | const normalized: ToolSourceState[] = []; | ||
| 52 | for (const entry of value) { | ||
| 53 | const record = asPayload(entry); | ||
| 54 | if (typeof record.id !== "string") continue; | ||
| 55 | if (typeof record.updatedAt !== "number") continue; | ||
| 56 | normalized.push({ | ||
| 57 | id: record.id, | ||
| 58 | updatedAt: record.updatedAt, | ||
| 59 | enabled: record.enabled !== false, | ||
| 60 | }); | ||
| 61 | } | ||
| 62 | |||
| 63 | return normalized; | ||
| 64 | } | ||
| 65 | |||
| 29 | async function readRegistryState( | 66 | async function readRegistryState( |
| 30 | ctx: Pick<ActionCtx, "runQuery">, | 67 | ctx: Pick<ActionCtx, "runQuery">, |
| 31 | workspaceId: Id<"workspaces">, | 68 | workspaceId: Id<"workspaces">, |
| 32 | ): Promise<{ buildId?: string; isReady: boolean }> { | 69 | ): Promise<{ buildId?: string; isReady: boolean }> { |
| 33 | const [state, sources] = await Promise.all([ | 70 | const [rawState, rawSources] = await Promise.all([ |
| 34 | ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as Promise<RegistryState>, | 71 | ctx.runQuery(internal.toolRegistry.getState, { workspaceId }), |
| 35 | ctx.runQuery(internal.database.listToolSources, { workspaceId }) as Promise<ToolSourceState[]>, | 72 | ctx.runQuery(internal.database.listToolSources, { workspaceId }), |
| 36 | ]); | 73 | ]); |
| 74 | const state = toRegistryState(rawState); | ||
| 75 | const sources = toToolSourceStateList(rawSources); | ||
| 37 | 76 | ||
| 38 | const expectedSignature = registrySignatureForWorkspace(workspaceId, sources); | 77 | const expectedSignature = registrySignatureForWorkspace(workspaceId, sources); |
| 39 | const buildId = state?.readyBuildId; | 78 | const buildId = state?.readyBuildId; |
| @@ -1,5 +1,6 @@ | |||
| 1 | "use node"; | 1 | "use node"; |
| 2 | 2 | ||
| 3 | import { Result } from "better-result"; | ||
| 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 { Id } from "../../convex/_generated/dataModel.d.ts"; | 6 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| @@ -40,58 +41,77 @@ export function normalizeExternalToolSource(raw: { · 34 unmodified lines | |||
| 40 | type: ToolSourceRecord["type"]; | 41 | type: ToolSourceRecord["type"]; |
| 41 | name: string; | 42 | name: string; |
| 42 | config: Record<string, unknown>; | 43 | config: Record<string, unknown>; |
| 43 | }): ExternalToolSourceConfig { | 44 | }): Result<ExternalToolSourceConfig, Error> { |
| 44 | const configResult = normalizeToolSourceConfig(raw.type, raw.config); | ||
| 45 | if (configResult.isErr()) { | ||
| 46 | throw new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`); | ||
| 47 | } | ||
| 48 | const config = configResult.value; | ||
| 49 | 45 | ||
| 50 | if (raw.type === "mcp") { | 46 | if (raw.type === "mcp") { |
| 47 | const configResult = normalizeToolSourceConfig("mcp", raw.config); | ||
| 48 | if (configResult.isErr()) { | ||
| 49 | return Result.err( | ||
| 50 | new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`), | ||
| 51 | ); | ||
| 52 | } | ||
| 53 | const config = configResult.value; | ||
| 51 | const result: McpToolSourceConfig = { | 54 | const result: McpToolSourceConfig = { |
| 52 | type: "mcp", | 55 | type: "mcp", |
| 53 | name: raw.name, | 56 | name: raw.name, |
| 54 | sourceId: raw.id, | 57 | sourceId: raw.id, |
| 55 | sourceKey: `source:${raw.id}`, | 58 | sourceKey: `source:${raw.id}`, |
| 56 | url: String(config.url), | 59 | url: config.url, |
| 57 | auth: config.auth as McpToolSourceConfig["auth"], | 60 | auth: config.auth, |
| 58 | transport: config.transport as McpToolSourceConfig["transport"], | 61 | transport: config.transport, |
| 59 | queryParams: config.queryParams as McpToolSourceConfig["queryParams"], | 62 | queryParams: config.queryParams, |
| 60 | defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"], | 63 | defaultApproval: config.defaultApproval, |
| 61 | overrides: config.overrides as McpToolSourceConfig["overrides"], | 64 | overrides: config.overrides, |
| 62 | }; | 65 | }; |
| 63 | return result; | 66 | return Result.ok(result); |
| 64 | } | 67 | } |
| 65 | 68 | ||
| 66 | if (raw.type === "graphql") { | 69 | if (raw.type === "graphql") { |
| 70 | const configResult = normalizeToolSourceConfig("graphql", raw.config); | ||
| 71 | if (configResult.isErr()) { | ||
| 72 | return Result.err( | ||
| 73 | new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`), | ||
| 74 | ); | ||
| 75 | } | ||
| 76 | const config = configResult.value; | ||
| 67 | const result: GraphqlToolSourceConfig = { | 77 | const result: GraphqlToolSourceConfig = { |
| 68 | type: "graphql", | 78 | type: "graphql", |
| 69 | name: raw.name, | 79 | name: raw.name, |
| 70 | sourceId: raw.id, | 80 | sourceId: raw.id, |
| 71 | sourceKey: `source:${raw.id}`, | 81 | sourceKey: `source:${raw.id}`, |
| 72 | endpoint: String(config.endpoint), | 82 | endpoint: config.endpoint, |
| 73 | schema: config.schema as GraphqlToolSourceConfig["schema"], | 83 | schema: config.schema, |
| 74 | auth: config.auth as GraphqlToolSourceConfig["auth"], | 84 | auth: config.auth, |
| 75 | defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"], | 85 | defaultQueryApproval: config.defaultQueryApproval, |
| 76 | defaultMutationApproval: config.defaultMutationApproval as GraphqlToolSourceConfig["defaultMutationApproval"], | 86 | defaultMutationApproval: config.defaultMutationApproval, |
| 77 | overrides: config.overrides as GraphqlToolSourceConfig["overrides"], | 87 | overrides: config.overrides, |
| 78 | }; | 88 | }; |
| 79 | return result; | 89 | return Result.ok(result); |
| 90 | } | ||
| 91 | |||
| 92 | const configResult = normalizeToolSourceConfig("openapi", raw.config); | ||
| 93 | if (configResult.isErr()) { | ||
| 94 | return Result.err( | ||
| 95 | new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`), | ||
| 96 | ); | ||
| 80 | } | 97 | } |
| 98 | const config = configResult.value; | ||
| 81 | 99 | ||
| 82 | const result: OpenApiToolSourceConfig = { | 100 | const result: OpenApiToolSourceConfig = { |
| 83 | type: "openapi", | 101 | type: "openapi", |
| 84 | name: raw.name, | 102 | name: raw.name, |
| 85 | sourceId: raw.id, | 103 | sourceId: raw.id, |
| 86 | sourceKey: `source:${raw.id}`, | 104 | sourceKey: `source:${raw.id}`, |
| 87 | spec: config.spec as OpenApiToolSourceConfig["spec"], | 105 | spec: config.spec, |
| 88 | baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"], | 106 | collectionUrl: config.collectionUrl, |
| 89 | auth: config.auth as OpenApiToolSourceConfig["auth"], | 107 | postmanProxyUrl: config.postmanProxyUrl, |
| 90 | defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"], | 108 | baseUrl: config.baseUrl, |
| 91 | defaultWriteApproval: config.defaultWriteApproval as OpenApiToolSourceConfig["defaultWriteApproval"], | 109 | auth: config.auth, |
| 92 | overrides: config.overrides as OpenApiToolSourceConfig["overrides"], | 110 | defaultReadApproval: config.defaultReadApproval, |
| 111 | defaultWriteApproval: config.defaultWriteApproval, | ||
| 112 | overrides: config.overrides, | ||
| 93 | }; | 113 | }; |
| 94 | return result; | 114 | return Result.ok(result); |
| 95 | } | 115 | } |
| 96 | 116 | ||
| 97 | function buildHeadersFromCredentialSecret( | 117 | function buildHeadersFromCredentialSecret( |
| @@ -26,6 +26,7 @@ import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_ · 25 unmodified lines | |||
| 26 | import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading"; | 26 | import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading"; |
| 27 | import { registrySignatureForWorkspace } from "./tool_registry_state"; | 27 | import { registrySignatureForWorkspace } from "./tool_registry_state"; |
| 28 | import { normalizeToolPathForLookup } from "./tool_paths"; | 28 | import { normalizeToolPathForLookup } from "./tool_paths"; |
| 29 | import { asPayload } from "../lib/object"; | ||
| 29 | 30 | ||
| 30 | const baseTools = new Map<string, ToolDefinition>(); | 31 | const baseTools = new Map<string, ToolDefinition>(); |
| 31 | 32 | ||
| @@ -58,7 +59,7 @@ baseTools.set("admin.send_announcement", { · 26 unmodified lines | |||
| 58 | }, | 59 | }, |
| 59 | }, | 60 | }, |
| 60 | run: async (input: unknown) => { | 61 | run: async (input: unknown) => { |
| 61 | const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {}; | 62 | const payload = asPayload(input); |
| 62 | const channel = typeof payload.channel === "string" ? payload.channel : ""; | 63 | const channel = typeof payload.channel === "string" ? payload.channel : ""; |
| 63 | const message = typeof payload.message === "string" ? payload.message : ""; | 64 | const message = typeof payload.message === "string" ? payload.message : ""; |
| 64 | return { ok: true, channel, message }; | 65 | return { ok: true, channel, message }; |
| @@ -365,7 +366,7 @@ function asJsonSchema(value: unknown): JsonSchema { · 300 unmodified lines | |||
| 365 | if (!value || typeof value !== "object" || Array.isArray(value)) { | 366 | if (!value || typeof value !== "object" || Array.isArray(value)) { |
| 366 | return {}; | 367 | return {}; |
| 367 | } | 368 | } |
| 368 | return value as JsonSchema; | 369 | return asPayload(value); |
| 369 | } | 370 | } |
| 370 | 371 | ||
| 371 | async function buildWorkspaceToolRegistry( | 372 | async function buildWorkspaceToolRegistry( |
| @@ -513,7 +514,7 @@ export async function getWorkspaceTools( · 141 unmodified lines | |||
| 513 | if (source.type !== "mcp") { | 514 | if (source.type !== "mcp") { |
| 514 | return false; | 515 | return false; |
| 515 | } | 516 | } |
| 516 | const auth = source.config.auth as Record<string, unknown> | undefined; | 517 | const auth = asPayload(source.config.auth); |
| 517 | return auth?.mode === "actor"; | 518 | return auth?.mode === "actor"; |
| 518 | }); | 519 | }); |
| 519 | const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource; | 520 | const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource; |
| @@ -598,12 +599,12 @@ export async function getWorkspaceTools( · 78 unmodified lines | |||
| 598 | const warnings: string[] = []; | 599 | const warnings: string[] = []; |
| 599 | const normalizeSourcesStartedAt = Date.now(); | 600 | const normalizeSourcesStartedAt = Date.now(); |
| 600 | for (const source of sources) { | 601 | for (const source of sources) { |
| 601 | try { | 602 | const normalizedResult = normalizeExternalToolSource(source); |
| 602 | configs.push(normalizeExternalToolSource(source)); | 603 | if (normalizedResult.isErr()) { |
| 603 | } catch (error) { | 604 | warnings.push(`Source '${source.name}': ${normalizedResult.error.message}`); |
| 604 | const message = error instanceof Error ? error.message : String(error); | 605 | continue; |
| 605 | warnings.push(`Source '${source.name}': ${message}`); | ||
| 606 | } | 606 | } |
| 607 | configs.push(normalizedResult.value); | ||
| 607 | } | 608 | } |
| 608 | traceStep("normalizeSources", normalizeSourcesStartedAt); | 609 | traceStep("normalizeSources", normalizeSourcesStartedAt); |
| 609 | 610 | ||