Unified · Split
| @@ -1,5 +1,10 @@ | |||
| 1 | import type { NextRequest } from "next/server"; | 1 | import type { NextRequest } from "next/server"; |
| 2 | import { NextResponse } from "next/server"; | 2 | import { NextResponse } from "next/server"; |
| 3 | import { z } from "zod"; | ||
| 4 | |||
| 5 | const anonymousTokenRequestSchema = z.object({ | ||
| 6 | actorId: z.string().trim().min(1).optional(), | ||
| 7 | }); | ||
| 3 | 8 | ||
| 4 | function toSiteUrl(convexUrl?: string): string | null { | 9 | function toSiteUrl(convexUrl?: string): string | null { |
| 5 | if (!convexUrl) { | 10 | if (!convexUrl) { |
| @@ -48,9 +53,9 @@ export async function POST(request: NextRequest) { · 42 unmodified lines | |||
| 48 | 53 | ||
| 49 | let body: { actorId?: string } = {}; | 54 | let body: { actorId?: string } = {}; |
| 50 | try { | 55 | try { |
| 51 | const parsed = await request.json() as { actorId?: unknown }; | 56 | const parsed = anonymousTokenRequestSchema.safeParse(await request.json()); |
| 52 | if (typeof parsed.actorId === "string" && parsed.actorId.trim().length > 0) { | 57 | if (parsed.success && parsed.data.actorId) { |
| 53 | body.actorId = parsed.actorId.trim(); | 58 | body.actorId = parsed.data.actorId; |
| 54 | } | 59 | } |
| 55 | } catch { | 60 | } catch { |
| 56 | body = {}; | 61 | body = {}; |
| @@ -67,7 +72,7 @@ export async function POST(request: NextRequest) { · 10 unmodified lines | |||
| 67 | }); | 72 | }); |
| 68 | 73 | ||
| 69 | const text = await response.text(); | 74 | const text = await response.text(); |
| 70 | let payload: unknown = null; | 75 | let payload: unknown; |
| 71 | try { | 76 | try { |
| 72 | payload = JSON.parse(text); | 77 | payload = JSON.parse(text); |
| 73 | } catch { | 78 | } catch { |
| @@ -0,0 +1,51 @@ | |||
| 1 | import { expect, test } from "bun:test"; | ||
| 2 | import { | ||
| 3 | decodePendingCookieValue, | ||
| 4 | decodePopupResultCookieValue, | ||
| 5 | encodePendingCookieValue, | ||
| 6 | encodePopupResultCookieValue, | ||
| 7 | type McpOAuthPending, | ||
| 8 | type McpOAuthPopupResult, | ||
| 9 | } from "./oauth-provider"; | ||
| 10 | |||
| 11 | test("pending cookie round-trips with versioned envelope", () => { | ||
| 12 | const pending: McpOAuthPending = { | ||
| 13 | state: "state-123", | ||
| 14 | sourceUrl: "https://example.com/mcp", | ||
| 15 | redirectUrl: "https://app.example.com/oauth/callback", | ||
| 16 | codeVerifier: "verifier-123", | ||
| 17 | }; | ||
| 18 | |||
| 19 | const encoded = encodePendingCookieValue(pending); | ||
| 20 | expect(decodePendingCookieValue(encoded)).toEqual(pending); | ||
| 21 | }); | ||
| 22 | |||
| 23 | test("pending cookie decoder supports legacy payload shape", () => { | ||
| 24 | const legacyPayload = { | ||
| 25 | state: "legacy-state", | ||
| 26 | sourceUrl: "https://legacy.example.com/mcp", | ||
| 27 | redirectUrl: "https://legacy.example.com/callback", | ||
| 28 | }; | ||
| 29 | const raw = Buffer.from(JSON.stringify(legacyPayload), "utf8").toString("base64url"); | ||
| 30 | |||
| 31 | expect(decodePendingCookieValue(raw)).toEqual(legacyPayload); | ||
| 32 | }); | ||
| 33 | |||
| 34 | test("popup cookie round-trips with versioned envelope", () => { | ||
| 35 | const result: McpOAuthPopupResult = { | ||
| 36 | ok: true, | ||
| 37 | sourceUrl: "https://example.com/mcp", | ||
| 38 | accessToken: "token-123", | ||
| 39 | refreshToken: "refresh-123", | ||
| 40 | scope: "read write", | ||
| 41 | expiresIn: 3600, | ||
| 42 | }; | ||
| 43 | |||
| 44 | const encoded = encodePopupResultCookieValue(result); | ||
| 45 | expect(decodePopupResultCookieValue(encoded)).toEqual(result); | ||
| 46 | }); | ||
| 47 | |||
| 48 | test("popup cookie decoder rejects invalid payloads", () => { | ||
| 49 | const invalidRaw = Buffer.from(JSON.stringify({ ok: "yes" }), "utf8").toString("base64url"); | ||
| 50 | expect(decodePopupResultCookieValue(invalidRaw)).toBeNull(); | ||
| 51 | }); | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { Result } from "better-result"; | 2 | import { Result } from "better-result"; |
| 3 | import { z } from "zod"; | ||
| 3 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; | 4 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 4 | import type { | 5 | import type { |
| 5 | OAuthClientInformationMixed, | 6 | OAuthClientInformationMixed, |
| @@ -28,6 +29,44 @@ export type McpOAuthPopupResult = { · 22 unmodified lines | |||
| 28 | const COOKIE_PREFIX = "executor_mcp_oauth_"; | 29 | const COOKIE_PREFIX = "executor_mcp_oauth_"; |
| 29 | export const MCP_OAUTH_RESULT_COOKIE = "executor_mcp_oauth_result"; | 30 | export const MCP_OAUTH_RESULT_COOKIE = "executor_mcp_oauth_result"; |
| 30 | 31 | ||
| 32 | const pendingSchema = z.object({ | ||
| 33 | state: z.string(), | ||
| 34 | sourceUrl: z.string(), | ||
| 35 | redirectUrl: z.string(), | ||
| 36 | codeVerifier: z.string().optional(), | ||
| 37 | clientInformation: z.custom<OAuthClientInformationMixed>().optional(), | ||
| 38 | }); | ||
| 39 | |||
| 40 | const versionedPendingSchema = z.object({ | ||
| 41 | version: z.literal(1), | ||
| 42 | pending: pendingSchema, | ||
| 43 | }); | ||
| 44 | |||
| 45 | const popupResultSchema = z.object({ | ||
| 46 | ok: z.boolean(), | ||
| 47 | sourceUrl: z.string().optional(), | ||
| 48 | accessToken: z.string().optional(), | ||
| 49 | refreshToken: z.string().optional(), | ||
| 50 | scope: z.string().optional(), | ||
| 51 | expiresIn: z.number().optional(), | ||
| 52 | error: z.string().optional(), | ||
| 53 | }); | ||
| 54 | |||
| 55 | const versionedPopupResultSchema = z.object({ | ||
| 56 | version: z.literal(1), | ||
| 57 | result: popupResultSchema, | ||
| 58 | }); | ||
| 59 | |||
| 60 | function decodeCookieJson(raw: string): unknown | null { | ||
| 61 | const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8")); | ||
| 62 | if (decodedResult.isErr()) { | ||
| 63 | return null; | ||
| 64 | } | ||
| 65 | |||
| 66 | const parsedResult = Result.try(() => JSON.parse(decodedResult.value)); | ||
| 67 | return parsedResult.isOk() ? parsedResult.value : null; | ||
| 68 | } | ||
| 69 | |||
| 31 | export function buildPendingCookieName(state: string): string { | 70 | export function buildPendingCookieName(state: string): string { |
| 32 | return `${COOKIE_PREFIX}${state}`; | 71 | return `${COOKIE_PREFIX}${state}`; |
| 33 | } | 72 | } |
| @@ -37,67 +76,41 @@ export function createOAuthState(): string { · 3 unmodified lines | |||
| 37 | } | 76 | } |
| 38 | 77 | ||
| 39 | export function encodePendingCookieValue(pending: McpOAuthPending): string { | 78 | export function encodePendingCookieValue(pending: McpOAuthPending): string { |
| 40 | return Buffer.from(JSON.stringify(pending), "utf8").toString("base64url"); | 79 | return Buffer.from(JSON.stringify({ version: 1, pending }), "utf8").toString("base64url"); |
| 41 | } | 80 | } |
| 42 | 81 | ||
| 43 | export function decodePendingCookieValue(raw: string): McpOAuthPending | null { | 82 | export function decodePendingCookieValue(raw: string): McpOAuthPending | null { |
| 44 | const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8")); | 83 | const decoded = decodeCookieJson(raw); |
| 45 | if (!decodedResult.isOk()) { | 84 | if (!decoded) { |
| 46 | return null; | ||
| 47 | } | ||
| 48 | |||
| 49 | const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPending>); | ||
| 50 | if (!parsedResult.isOk()) { | ||
| 51 | return null; | 85 | return null; |
| 52 | } | 86 | } |
| 53 | 87 | ||
| 54 | const parsed = parsedResult.value; | 88 | const versionedResult = versionedPendingSchema.safeParse(decoded); |
| 55 | if ( | 89 | if (versionedResult.success) { |
| 56 | typeof parsed.state !== "string" | 90 | return versionedResult.data.pending; |
| 57 | || typeof parsed.sourceUrl !== "string" | ||
| 58 | || typeof parsed.redirectUrl !== "string" | ||
| 59 | ) { | ||
| 60 | return null; | ||
| 61 | } | 91 | } |
| 62 | 92 | ||
| 63 | return { | 93 | const legacyResult = pendingSchema.safeParse(decoded); |
| 64 | state: parsed.state, | 94 | return legacyResult.success ? legacyResult.data : null; |
| 65 | sourceUrl: parsed.sourceUrl, | ||
| 66 | redirectUrl: parsed.redirectUrl, | ||
| 67 | ...(typeof parsed.codeVerifier === "string" ? { codeVerifier: parsed.codeVerifier } : {}), | ||
| 68 | ...(parsed.clientInformation ? { clientInformation: parsed.clientInformation } : {}), | ||
| 69 | }; | ||
| 70 | } | 95 | } |
| 71 | 96 | ||
| 72 | export function encodePopupResultCookieValue(result: McpOAuthPopupResult): string { | 97 | export function encodePopupResultCookieValue(result: McpOAuthPopupResult): string { |
| 73 | return Buffer.from(JSON.stringify(result), "utf8").toString("base64url"); | 98 | return Buffer.from(JSON.stringify({ version: 1, result }), "utf8").toString("base64url"); |
| 74 | } | 99 | } |
| 75 | 100 | ||
| 76 | export function decodePopupResultCookieValue(raw: string): McpOAuthPopupResult | null { | 101 | export function decodePopupResultCookieValue(raw: string): McpOAuthPopupResult | null { |
| 77 | const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8")); | 102 | const decoded = decodeCookieJson(raw); |
| 78 | if (!decodedResult.isOk()) { | 103 | if (!decoded) { |
| 79 | return null; | 104 | return null; |
| 80 | } | 105 | } |
| 81 | 106 | ||
| 82 | const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPopupResult>); | 107 | const versionedResult = versionedPopupResultSchema.safeParse(decoded); |
| 83 | if (!parsedResult.isOk()) { | 108 | if (versionedResult.success) { |
| 84 | return null; | 109 | return versionedResult.data.result; |
| 85 | } | ||
| 86 | |||
| 87 | const parsed = parsedResult.value; | ||
| 88 | if (typeof parsed.ok !== "boolean") { | ||
| 89 | return null; | ||
| 90 | } | 110 | } |
| 91 | 111 | ||
| 92 | return { | 112 | const legacyResult = popupResultSchema.safeParse(decoded); |
| 93 | ok: parsed.ok, | 113 | return legacyResult.success ? legacyResult.data : null; |
| 94 | ...(typeof parsed.sourceUrl === "string" ? { sourceUrl: parsed.sourceUrl } : {}), | ||
| 95 | ...(typeof parsed.accessToken === "string" ? { accessToken: parsed.accessToken } : {}), | ||
| 96 | ...(typeof parsed.refreshToken === "string" ? { refreshToken: parsed.refreshToken } : {}), | ||
| 97 | ...(typeof parsed.scope === "string" ? { scope: parsed.scope } : {}), | ||
| 98 | ...(typeof parsed.expiresIn === "number" ? { expiresIn: parsed.expiresIn } : {}), | ||
| 99 | ...(typeof parsed.error === "string" ? { error: parsed.error } : {}), | ||
| 100 | }; | ||
| 101 | } | 114 | } |
| 102 | 115 | ||
| 103 | export class McpPopupOAuthProvider implements OAuthClientProvider { | 116 | export class McpPopupOAuthProvider implements OAuthClientProvider { |
| @@ -1,4 +1,6 @@ | |||
| 1 | import { Result } from "better-result"; | ||
| 1 | import { parse as parseYaml } from "yaml"; | 2 | import { parse as parseYaml } from "yaml"; |
| 3 | import { z } from "zod"; | ||
| 2 | import type { CredentialScope, SourceAuthType } from "@/lib/types"; | 4 | import type { CredentialScope, SourceAuthType } from "@/lib/types"; |
| 3 | 5 | ||
| 4 | type SupportedAuthType = Exclude<SourceAuthType, "none" | "mixed">; | 6 | type SupportedAuthType = Exclude<SourceAuthType, "none" | "mixed">; |
| @@ -15,11 +17,36 @@ type OpenApiInspectionResult = { · 10 unmodified lines | |||
| 15 | inferredAuth: InferredSpecAuth; | 17 | inferredAuth: InferredSpecAuth; |
| 16 | }; | 18 | }; |
| 17 | 19 | ||
| 18 | function asRecord(value: unknown): Record<string, unknown> { | 20 | const recordSchema = z.record(z.unknown()); |
| 19 | if (!value || typeof value !== "object" || Array.isArray(value)) { | 21 | |
| 20 | return {}; | 22 | const securityRequirementSchema = z.record(z.array(z.unknown()).optional()); |
| 23 | |||
| 24 | const securitySchemeSchema = z.object({ | ||
| 25 | type: z.string().optional(), | ||
| 26 | scheme: z.string().optional(), | ||
| 27 | in: z.string().optional(), | ||
| 28 | name: z.string().optional(), | ||
| 29 | }); | ||
| 30 | |||
| 31 | function toRecordValue(value: unknown): Record<string, unknown> { | ||
| 32 | const parsed = recordSchema.safeParse(value); | ||
| 33 | return parsed.success ? parsed.data : {}; | ||
| 34 | } | ||
| 35 | |||
| 36 | function parseObjectFromText(text: string, format: "json" | "yaml"): Record<string, unknown> | null { | ||
| 37 | const parsed = format === "json" | ||
| 38 | ? Result.try(() => JSON.parse(text)) | ||
| 39 | : Result.try(() => parseYaml(text)); | ||
| 40 | if (parsed.isErr()) { | ||
| 41 | return null; | ||
| 42 | } | ||
| 43 | |||
| 44 | const parsedRecord = recordSchema.safeParse(parsed.value); | ||
| 45 | if (!parsedRecord.success || Object.keys(parsedRecord.data).length === 0) { | ||
| 46 | return null; | ||
| 21 | } | 47 | } |
| 22 | return value as Record<string, unknown>; | 48 | |
| 49 | return parsedRecord.data; | ||
| 23 | } | 50 | } |
| 24 | 51 | ||
| 25 | function parseOpenApiPayload(raw: string, sourceUrl: string, contentType: string): Record<string, unknown> { | 52 | function parseOpenApiPayload(raw: string, sourceUrl: string, contentType: string): Record<string, unknown> { |
| @@ -27,40 +54,34 @@ function parseOpenApiPayload(raw: string, sourceUrl: string, contentType: string · 1 unmodified line | |||
| 27 | const loweredUrl = sourceUrl.toLowerCase(); | 54 | const loweredUrl = sourceUrl.toLowerCase(); |
| 28 | const preferJson = loweredContentType.includes("json") || loweredUrl.endsWith(".json"); | 55 | const preferJson = loweredContentType.includes("json") || loweredUrl.endsWith(".json"); |
| 29 | 56 | ||
| 30 | const tryJson = () => asRecord(JSON.parse(raw)); | 57 | const primary = preferJson |
| 31 | const tryYaml = () => asRecord(parseYaml(raw)); | 58 | ? parseObjectFromText(raw, "json") |
| 32 | 59 | : parseObjectFromText(raw, "yaml"); | |
| 33 | const parsed = preferJson | 60 | const fallback = preferJson |
| 34 | ? (() => { | 61 | ? parseObjectFromText(raw, "yaml") |
| 35 | try { | 62 | : parseObjectFromText(raw, "json"); |
| 36 | return tryJson(); | 63 | const parsed = primary ?? fallback; |
| 37 | } catch { | 64 | |
| 38 | return tryYaml(); | 65 | if (!parsed) { |
| 39 | } | ||
| 40 | })() | ||
| 41 | : (() => { | ||
| 42 | try { | ||
| 43 | return tryYaml(); | ||
| 44 | } catch { | ||
| 45 | return tryJson(); | ||
| 46 | } | ||
| 47 | })(); | ||
| 48 | |||
| 49 | if (Object.keys(parsed).length === 0) { | ||
| 50 | throw new Error("Spec payload is empty or not an object"); | 66 | throw new Error("Spec payload is empty or not an object"); |
| 51 | } | 67 | } |
| 52 | 68 | ||
| 53 | return parsed; | 69 | return parsed; |
| 54 | } | 70 | } |
| 55 | 71 | ||
| 56 | function normalizeAuthScheme(scheme: Record<string, unknown>): { | 72 | function normalizeAuthScheme(scheme: unknown): { |
| 57 | type: SupportedAuthType; | 73 | type: SupportedAuthType; |
| 58 | header?: string; | 74 | header?: string; |
| 59 | } | null { | 75 | } | null { |
| 60 | const type = String(scheme.type ?? "").toLowerCase(); | 76 | const parsedScheme = securitySchemeSchema.safeParse(scheme); |
| 77 | if (!parsedScheme.success) { | ||
| 78 | return null; | ||
| 79 | } | ||
| 80 | |||
| 81 | const type = (parsedScheme.data.type ?? "").toLowerCase(); | ||
| 61 | 82 | ||
| 62 | if (type === "http") { | 83 | if (type === "http") { |
| 63 | const httpScheme = String(scheme.scheme ?? "").toLowerCase(); | 84 | const httpScheme = (parsedScheme.data.scheme ?? "").toLowerCase(); |
| 64 | if (httpScheme === "bearer") { | 85 | if (httpScheme === "bearer") { |
| 65 | return { type: "bearer" }; | 86 | return { type: "bearer" }; |
| 66 | } | 87 | } |
| @@ -71,8 +92,8 @@ function normalizeAuthScheme(scheme: Record<string, unknown>): { · 4 unmodified lines | |||
| 71 | } | 92 | } |
| 72 | 93 | ||
| 73 | if (type === "apikey") { | 94 | if (type === "apikey") { |
| 74 | const location = String(scheme.in ?? "").toLowerCase(); | 95 | const location = (parsedScheme.data.in ?? "").toLowerCase(); |
| 75 | const header = typeof scheme.name === "string" ? scheme.name.trim() : ""; | 96 | const header = (parsedScheme.data.name ?? "").trim(); |
| 76 | if (location === "header" && header.length > 0) { | 97 | if (location === "header" && header.length > 0) { |
| 77 | return { type: "apiKey", header }; | 98 | return { type: "apiKey", header }; |
| 78 | } | 99 | } |
| @@ -87,23 +108,26 @@ function normalizeAuthScheme(scheme: Record<string, unknown>): { · 8 unmodified lines | |||
| 87 | } | 108 | } |
| 88 | 109 | ||
| 89 | function inferSecuritySchemaAuth(spec: Record<string, unknown>): InferredSpecAuth { | 110 | function inferSecuritySchemaAuth(spec: Record<string, unknown>): InferredSpecAuth { |
| 90 | const components = asRecord(spec.components); | 111 | const components = toRecordValue(spec.components); |
| 91 | const securitySchemes = asRecord(components.securitySchemes); | 112 | const securitySchemes = toRecordValue(components.securitySchemes); |
| 92 | const schemeNames = Object.keys(securitySchemes); | 113 | const schemeNames = Object.keys(securitySchemes); |
| 93 | if (schemeNames.length === 0) { | 114 | if (schemeNames.length === 0) { |
| 94 | return { type: "none", inferred: true }; | 115 | return { type: "none", inferred: true }; |
| 95 | } | 116 | } |
| 96 | 117 | ||
| 97 | const globalSecurity = Array.isArray(spec.security) | 118 | const globalSecurity = Array.isArray(spec.security) |
| 98 | ? spec.security.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object")) | 119 | ? spec.security.flatMap((entry) => { |
| 120 | const parsed = securityRequirementSchema.safeParse(entry); | ||
| 121 | return parsed.success ? [parsed.data] : []; | ||
| 122 | }) | ||
| 99 | : []; | 123 | : []; |
| 100 | const referencedSchemeNames = globalSecurity.flatMap((entry) => Object.keys(entry)); | 124 | const referencedSchemeNames = globalSecurity.flatMap((entry) => Object.keys(entry)); |
| 101 | const candidateNames = referencedSchemeNames.length > 0 | 125 | const candidateNames = referencedSchemeNames.length > 0 |
| 102 | ? [...new Set(referencedSchemeNames.filter((name) => typeof securitySchemes[name] === "object"))] | 126 | ? [...new Set(referencedSchemeNames.filter((name) => Object.prototype.hasOwnProperty.call(securitySchemes, name)))] |
| 103 | : schemeNames; | 127 | : schemeNames; |
| 104 | 128 | ||
| 105 | const normalized = candidateNames | 129 | const normalized = candidateNames |
| 106 | .map((name) => normalizeAuthScheme(asRecord(securitySchemes[name]))) | 130 | .map((name) => normalizeAuthScheme(securitySchemes[name])) |
| 107 | .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)); | 131 | .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)); |
| 108 | 132 | ||
| 109 | if (normalized.length === 0) { | 133 | if (normalized.length === 0) { |
| @@ -1,9 +1,16 @@ | |||
| 1 | import type { Id } from "@executor/database/convex/_generated/dataModel"; | 1 | import type { Id } from "@executor/database/convex/_generated/dataModel"; |
| 2 | import { z } from "zod"; | ||
| 2 | 3 | ||
| 3 | const SESSION_KEY = "executor_session_id"; | 4 | const SESSION_KEY = "executor_session_id"; |
| 4 | const ACTIVE_WORKSPACE_KEY = "executor_active_workspace_id"; | 5 | const ACTIVE_WORKSPACE_KEY = "executor_active_workspace_id"; |
| 5 | const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account"; | 6 | const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account"; |
| 6 | 7 | ||
| 8 | const workspaceByAccountSchema = z.record(z.string()); | ||
| 9 | |||
| 10 | function isWorkspaceId(value: string): value is Id<"workspaces"> { | ||
| 11 | return value.trim().length > 0; | ||
| 12 | } | ||
| 13 | |||
| 7 | export function readStoredSessionId() { | 14 | export function readStoredSessionId() { |
| 8 | if (typeof window === "undefined") { | 15 | if (typeof window === "undefined") { |
| 9 | return null; | 16 | return null; |
| @@ -17,7 +24,12 @@ export function readStoredActiveWorkspaceId() { · 7 unmodified lines | |||
| 17 | return null; | 24 | return null; |
| 18 | } | 25 | } |
| 19 | 26 | ||
| 20 | return localStorage.getItem(ACTIVE_WORKSPACE_KEY) as Id<"workspaces"> | null; | 27 | const stored = localStorage.getItem(ACTIVE_WORKSPACE_KEY); |
| 28 | if (!stored || !isWorkspaceId(stored)) { | ||
| 29 | return null; | ||
| 30 | } | ||
| 31 | |||
| 32 | return stored; | ||
| 21 | } | 33 | } |
| 22 | 34 | ||
| 23 | export function persistSessionId(sessionId: string) { | 35 | export function persistSessionId(sessionId: string) { |
| @@ -33,18 +45,50 @@ export function clearSessionStorage() { · 9 unmodified lines | |||
| 33 | localStorage.removeItem(ACTIVE_WORKSPACE_KEY); | 45 | localStorage.removeItem(ACTIVE_WORKSPACE_KEY); |
| 34 | } | 46 | } |
| 35 | 47 | ||
| 36 | export function readWorkspaceByAccount() { | 48 | export function readWorkspaceByAccount(): Record<string, Id<"workspaces">> { |
| 49 | if (typeof window === "undefined") { | ||
| 50 | return {}; | ||
| 51 | } | ||
| 52 | |||
| 37 | const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY); | 53 | const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY); |
| 38 | if (!raw) { | 54 | if (!raw) { |
| 39 | return {} as Record<string, Id<"workspaces">>; | 55 | return {}; |
| 40 | } | 56 | } |
| 41 | 57 | ||
| 58 | let parsedJson: unknown; | ||
| 42 | try { | 59 | try { |
| 43 | const parsed = JSON.parse(raw) as Record<string, Id<"workspaces">>; | 60 | parsedJson = JSON.parse(raw); |
| 44 | return parsed ?? {}; | ||
| 45 | } catch { | 61 | } catch { |
| 46 | return {}; | 62 | return {}; |
| 47 | } | 63 | } |
| 64 | |||
| 65 | const parsedMap = workspaceByAccountSchema.safeParse(parsedJson); | ||
| 66 | if (!parsedMap.success) { | ||
| 67 | return {}; | ||
| 68 | } | ||
| 69 | |||
| 70 | let changed = false; | ||
| 71 | const cleaned: Record<string, Id<"workspaces">> = {}; | ||
| 72 | for (const [accountIdRaw, workspaceIdRaw] of Object.entries(parsedMap.data)) { | ||
| 73 | const accountId = accountIdRaw.trim(); | ||
| 74 | const workspaceId = workspaceIdRaw.trim(); | ||
| 75 | if (!accountId || !isWorkspaceId(workspaceId)) { | ||
| 76 | changed = true; | ||
| 77 | continue; | ||
| 78 | } | ||
| 79 | |||
| 80 | if (accountId !== accountIdRaw || workspaceId !== workspaceIdRaw) { | ||
| 81 | changed = true; | ||
| 82 | } | ||
| 83 | |||
| 84 | cleaned[accountId] = workspaceId; | ||
| 85 | } | ||
| 86 | |||
| 87 | if (changed) { | ||
| 88 | writeWorkspaceByAccount(cleaned); | ||
| 89 | } | ||
| 90 | |||
| 91 | return cleaned; | ||
| 48 | } | 92 | } |
| 49 | 93 | ||
| 50 | function writeWorkspaceByAccount(value: Record<string, Id<"workspaces">>) { | 94 | function writeWorkspaceByAccount(value: Record<string, Id<"workspaces">>) { |
| @@ -5,8 +5,13 @@ import type { Id } from "@executor/database/convex/_generated/dataModel"; · 4 unmodified lines | |||
| 5 | export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_out" | "denied"; | 5 | export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_out" | "denied"; |
| 6 | export type ApprovalStatus = "pending" | "approved" | "denied"; | 6 | export type ApprovalStatus = "pending" | "approved" | "denied"; |
| 7 | export type PolicyDecision = "allow" | "require_approval" | "deny"; | 7 | export type PolicyDecision = "allow" | "require_approval" | "deny"; |
| 8 | export type PolicyScopeType = "organization" | "workspace"; | ||
| 9 | export type PolicyMatchType = "glob" | "exact"; | ||
| 10 | export type PolicyEffect = "allow" | "deny"; | ||
| 11 | export type PolicyApprovalMode = "inherit" | "auto" | "required"; | ||
| 8 | export type CredentialScope = "workspace" | "actor"; | 12 | export type CredentialScope = "workspace" | "actor"; |
| 9 | export type CredentialProvider = "local-convex" | "workos-vault"; | 13 | export type CredentialProvider = "local-convex" | "workos-vault"; |
| 14 | export type OwnerScopeType = "organization" | "workspace"; | ||
| 10 | export type ToolApprovalMode = "auto" | "required"; | 15 | export type ToolApprovalMode = "auto" | "required"; |
| 11 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; | 16 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; |
| 12 | 17 | ||
| @@ -65,9 +70,17 @@ export interface TaskEventRecord { · 52 unmodified lines | |||
| 65 | 70 | ||
| 66 | export interface AccessPolicyRecord { | 71 | export interface AccessPolicyRecord { |
| 67 | id: string; | 72 | id: string; |
| 68 | workspaceId: string; | 73 | scopeType?: PolicyScopeType; |
| 74 | organizationId?: string; | ||
| 75 | workspaceId?: string; | ||
| 76 | targetActorId?: string; | ||
| 69 | actorId?: string; | 77 | actorId?: string; |
| 70 | clientId?: string; | 78 | clientId?: string; |
| 79 | resourceType?: "tool_path"; | ||
| 80 | resourcePattern?: string; | ||
| 81 | matchType?: PolicyMatchType; | ||
| 82 | effect?: PolicyEffect; | ||
| 83 | approvalMode?: PolicyApprovalMode; | ||
| 71 | toolPathPattern: string; | 84 | toolPathPattern: string; |
| 72 | decision: PolicyDecision; | 85 | decision: PolicyDecision; |
| 73 | priority: number; | 86 | priority: number; |
| @@ -78,7 +91,9 @@ export interface AccessPolicyRecord { · 4 unmodified lines | |||
| 78 | export interface CredentialRecord { | 91 | export interface CredentialRecord { |
| 79 | id: string; | 92 | id: string; |
| 80 | bindingId?: string; | 93 | bindingId?: string; |
| 81 | workspaceId: string; | 94 | ownerScopeType?: OwnerScopeType; |
| 95 | organizationId?: string; | ||
| 96 | workspaceId?: string; | ||
| 82 | sourceKey: string; | 97 | sourceKey: string; |
| 83 | scope: CredentialScope; | 98 | scope: CredentialScope; |
| 84 | actorId?: string; | 99 | actorId?: string; |
| @@ -92,10 +107,14 @@ export interface CredentialRecord { · 7 unmodified lines | |||
| 92 | 107 | ||
| 93 | export interface ToolSourceRecord { | 108 | export interface ToolSourceRecord { |
| 94 | id: string; | 109 | id: string; |
| 95 | workspaceId: string; | 110 | ownerScopeType?: OwnerScopeType; |
| 111 | organizationId?: string; | ||
| 112 | workspaceId?: string; | ||
| 96 | name: string; | 113 | name: string; |
| 97 | type: ToolSourceType; | 114 | type: ToolSourceType; |
| 98 | config: Record<string, unknown>; | 115 | config: Record<string, unknown>; |
| 116 | specHash?: string; | ||
| 117 | authFingerprint?: string; | ||
| 99 | enabled: boolean; | 118 | enabled: boolean; |
| 100 | createdAt: number; | 119 | createdAt: number; |
| 101 | updatedAt: number; | 120 | updatedAt: number; |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { expect, test } from "bun:test"; | 1 | import { expect, test } from "bun:test"; |
| 2 | import { resolveCredentialPayload } from "./credential-providers"; | 2 | import { resolveCredentialPayload, resolveCredentialPayloadResult } from "./credential-providers"; |
| 3 | 3 | ||
| 4 | test("local provider returns stored payload", async () => { | 4 | test("local provider returns stored payload", async () => { |
| 5 | const payload = await resolveCredentialPayload({ | 5 | const payload = await resolveCredentialPayload({ |
| @@ -46,6 +46,23 @@ test("WorkOS Vault provider falls back to id key and raw token", async () => { · 40 unmodified lines | |||
| 46 | expect(payload).toEqual({ token: "ghp_raw_token" }); | 46 | expect(payload).toEqual({ token: "ghp_raw_token" }); |
| 47 | }); | 47 | }); |
| 48 | 48 | ||
| 49 | test("WorkOS Vault provider parses env-style key/value payloads", async () => { | ||
| 50 | const payload = await resolveCredentialPayload( | ||
| 51 | { | ||
| 52 | provider: "workos-vault", | ||
| 53 | secretJson: { objectId: "secret_789" }, | ||
| 54 | }, | ||
| 55 | { | ||
| 56 | readVaultObject: async ({ objectId }) => { | ||
| 57 | expect(objectId).toBe("secret_789"); | ||
| 58 | return "TOKEN=vault-token\nHEADER_NAME=\"x-api-key\""; | ||
| 59 | }, | ||
| 60 | }, | ||
| 61 | ); | ||
| 62 | |||
| 63 | expect(payload).toEqual({ TOKEN: "vault-token", HEADER_NAME: "x-api-key" }); | ||
| 64 | }); | ||
| 65 | |||
| 49 | test("WorkOS Vault provider gives actionable error on missing reference", async () => { | 66 | test("WorkOS Vault provider gives actionable error on missing reference", async () => { |
| 50 | await expect( | 67 | await expect( |
| 51 | resolveCredentialPayload({ | 68 | resolveCredentialPayload({ |
| @@ -54,3 +71,15 @@ test("WorkOS Vault provider gives actionable error on missing reference", async · 2 unmodified lines | |||
| 54 | }), | 71 | }), |
| 55 | ).rejects.toThrow("Re-save this credential"); | 72 | ).rejects.toThrow("Re-save this credential"); |
| 56 | }); | 73 | }); |
| 74 | |||
| 75 | test("resolveCredentialPayloadResult returns Err for missing WorkOS reference", async () => { | ||
| 76 | const result = await resolveCredentialPayloadResult({ | ||
| 77 | provider: "workos-vault", | ||
| 78 | secretJson: {}, | ||
| 79 | }); | ||
| 80 | |||
| 81 | expect(result.isErr()).toBe(true); | ||
| 82 | if (result.isErr()) { | ||
| 83 | expect(result.error.message).toContain("Re-save this credential"); | ||
| 84 | } | ||
| 85 | }); | ||
| @@ -1,10 +1,13 @@ | |||
| 1 | "use node"; | 1 | "use node"; |
| 2 | 2 | ||
| 3 | import { WorkOS } from "@workos-inc/node"; | ||
| 4 | import { Result } from "better-result"; | 3 | import { Result } from "better-result"; |
| 5 | import { z } from "zod"; | 4 | import { z } from "zod"; |
| 5 | import { | ||
| 6 | createWorkosClient, | ||
| 7 | parseWorkosVaultReference, | ||
| 8 | withWorkosVaultRetryResult, | ||
| 9 | } from "./credentials/workos-vault"; | ||
| 6 | import type { CredentialProvider, CredentialRecord } from "./types"; | 10 | import type { CredentialProvider, CredentialRecord } from "./types"; |
| 7 | import { asRecord } from "./utils"; | ||
| 8 | 11 | ||
| 9 | type CredentialPayload = Record<string, unknown>; | 12 | type CredentialPayload = Record<string, unknown>; |
| 10 | 13 | ||
| @@ -18,67 +21,31 @@ type VaultObjectReader = (input: VaultReadInput) => Promise<string>; · 7 unmodified lines | |||
| 18 | type ProviderResolver = ( | 21 | type ProviderResolver = ( |
| 19 | record: Pick<CredentialRecord, "provider" | "secretJson">, | 22 | record: Pick<CredentialRecord, "provider" | "secretJson">, |
| 20 | readVaultObject: VaultObjectReader, | 23 | readVaultObject: VaultObjectReader, |
| 21 | ) => Promise<CredentialPayload | null>; | 24 | ) => Promise<Result<CredentialPayload | null, Error>>; |
| 22 | 25 | ||
| 23 | const workosVaultSecretConfigSchema = z.object({ | 26 | const recordSchema = z.record(z.unknown()); |
| 24 | objectId: z.string().optional(), | ||
| 25 | id: z.string().optional(), | ||
| 26 | apiKey: z.string().optional(), | ||
| 27 | }); | ||
| 28 | 27 | ||
| 29 | function resolveWorkosApiKey(explicitApiKey?: string): string { | 28 | function coerceRecord(value: unknown): Record<string, unknown> { |
| 30 | const candidate = | 29 | const parsed = recordSchema.safeParse(value); |
| 31 | explicitApiKey?.trim() || | 30 | return parsed.success ? parsed.data : {}; |
| 32 | process.env.WORKOS_API_KEY?.trim() || | ||
| 33 | ""; | ||
| 34 | |||
| 35 | if (!candidate) { | ||
| 36 | throw new Error("WorkOS Vault provider requires WORKOS_API_KEY"); | ||
| 37 | } | ||
| 38 | |||
| 39 | return candidate; | ||
| 40 | } | 31 | } |
| 41 | 32 | ||
| 42 | function isRetryableVaultError(error: unknown): boolean { | 33 | const envSecretKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/); |
| 43 | if (!(error instanceof Error)) { | ||
| 44 | return false; | ||
| 45 | } | ||
| 46 | const message = error.message.toLowerCase(); | ||
| 47 | return ( | ||
| 48 | message.includes("not yet ready") || | ||
| 49 | message.includes("can be retried") || | ||
| 50 | (message.includes("kek") && message.includes("ready")) | ||
| 51 | ); | ||
| 52 | } | ||
| 53 | 34 | ||
| 54 | async function sleep(ms: number): Promise<void> { | 35 | async function defaultReadVaultObject(input: VaultReadInput): Promise<string> { |
| 55 | await new Promise((resolve) => setTimeout(resolve, ms)); | 36 | const workosResult = createWorkosClient(input.apiKey); |
| 56 | } | 37 | if (workosResult.isErr()) { |
| 57 | 38 | throw new Error("WorkOS Vault provider requires WORKOS_API_KEY"); | |
| 58 | async function withVaultRetry<T>(operation: () => Promise<T>): Promise<T> { | ||
| 59 | const maxAttempts = 5; | ||
| 60 | let delayMs = 250; | ||
| 61 | |||
| 62 | for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { | ||
| 63 | try { | ||
| 64 | return await operation(); | ||
| 65 | } catch (error) { | ||
| 66 | if (!isRetryableVaultError(error) || attempt === maxAttempts) { | ||
| 67 | throw error; | ||
| 68 | } | ||
| 69 | await sleep(delayMs); | ||
| 70 | delayMs *= 2; | ||
| 71 | } | ||
| 72 | } | 39 | } |
| 73 | 40 | ||
| 74 | throw new Error("Unreachable retry state"); | 41 | const readResult = await withWorkosVaultRetryResult(async () => { |
| 75 | } | 42 | return await workosResult.value.vault.readObject({ id: input.objectId }); |
| 76 | |||
| 77 | async function defaultReadVaultObject(input: VaultReadInput): Promise<string> { | ||
| 78 | const workos = new WorkOS(resolveWorkosApiKey(input.apiKey)); | ||
| 79 | const object = await withVaultRetry(async () => { | ||
| 80 | return await workos.vault.readObject({ id: input.objectId }); | ||
| 81 | }); | 43 | }); |
| 44 | if (readResult.isErr()) { | ||
| 45 | throw readResult.error; | ||
| 46 | } | ||
| 47 | |||
| 48 | const object = readResult.value; | ||
| 82 | const value = typeof object.value === "string" ? object.value : ""; | 49 | const value = typeof object.value === "string" ? object.value : ""; |
| 83 | if (!value.trim()) { | 50 | if (!value.trim()) { |
| 84 | throw new Error(`WorkOS Vault object '${input.objectId}' returned an empty value`); | 51 | throw new Error(`WorkOS Vault object '${input.objectId}' returned an empty value`); |
| @@ -92,41 +59,91 @@ function parseSecretValue(raw: string): CredentialPayload { · 7 unmodified lines | |||
| 92 | return {}; | 59 | return {}; |
| 93 | } | 60 | } |
| 94 | 61 | ||
| 95 | const parsedJson = Result.try(() => JSON.parse(value)); | 62 | try { |
| 96 | if (parsedJson.isOk()) { | 63 | const parsed = JSON.parse(value); |
| 97 | const parsed = parsedJson.value; | 64 | const asObject = coerceRecord(parsed); |
| 98 | const asObject = asRecord(parsed); | ||
| 99 | if (Object.keys(asObject).length > 0) { | 65 | if (Object.keys(asObject).length > 0) { |
| 100 | return asObject; | 66 | return asObject; |
| 101 | } | 67 | } |
| 68 | } catch { | ||
| 69 | // Continue with env-style and token fallback parsing. | ||
| 70 | } | ||
| 71 | |||
| 72 | const envStyleResult = parseEnvStyleSecret(value); | ||
| 73 | if (envStyleResult) { | ||
| 74 | return envStyleResult; | ||
| 102 | } | 75 | } |
| 103 | 76 | ||
| 104 | return { token: value }; | 77 | return { token: value }; |
| 105 | } | 78 | } |
| 106 | 79 | ||
| 80 | function stripWrappingQuotes(value: string): string { | ||
| 81 | if (value.length < 2) { | ||
| 82 | return value; | ||
| 83 | } | ||
| 84 | |||
| 85 | const startsWithSingle = value.startsWith("'") && value.endsWith("'"); | ||
| 86 | const startsWithDouble = value.startsWith('"') && value.endsWith('"'); | ||
| 87 | return startsWithSingle || startsWithDouble ? value.slice(1, -1) : value; | ||
| 88 | } | ||
| 89 | |||
| 90 | function parseEnvStyleSecret(raw: string): CredentialPayload | null { | ||
| 91 | const lines = raw | ||
| 92 | .split(/\r?\n/) | ||
| 93 | .map((line) => line.trim()) | ||
| 94 | .filter((line) => line.length > 0 && !line.startsWith("#")); | ||
| 95 | if (lines.length === 0) { | ||
| 96 | return null; | ||
| 97 | } | ||
| 98 | |||
| 99 | const parsedPayload: CredentialPayload = {}; | ||
| 100 | for (const line of lines) { | ||
| 101 | const separatorIndex = line.indexOf("="); | ||
| 102 | if (separatorIndex <= 0) { | ||
| 103 | return null; | ||
| 104 | } | ||
| 105 | |||
| 106 | const keyRaw = line.slice(0, separatorIndex).trim(); | ||
| 107 | const keyResult = envSecretKeySchema.safeParse(keyRaw); | ||
| 108 | if (!keyResult.success) { | ||
| 109 | return null; | ||
| 110 | } | ||
| 111 | |||
| 112 | const valueRaw = line.slice(separatorIndex + 1).trim(); | ||
| 113 | parsedPayload[keyResult.data] = stripWrappingQuotes(valueRaw); | ||
| 114 | } | ||
| 115 | |||
| 116 | return Object.keys(parsedPayload).length > 0 ? parsedPayload : null; | ||
| 117 | } | ||
| 118 | |||
| 107 | async function resolveLocalConvex( | 119 | async function resolveLocalConvex( |
| 108 | record: Pick<CredentialRecord, "secretJson">, | 120 | record: Pick<CredentialRecord, "secretJson">, |
| 109 | ): Promise<CredentialPayload | null> { | 121 | ): Promise<Result<CredentialPayload | null, Error>> { |
| 110 | return asRecord(record.secretJson); | 122 | return Result.ok(coerceRecord(record.secretJson)); |
| 111 | } | 123 | } |
| 112 | 124 | ||
| 113 | async function resolveWorkosVault( | 125 | async function resolveWorkosVault( |
| 114 | record: Pick<CredentialRecord, "secretJson">, | 126 | record: Pick<CredentialRecord, "secretJson">, |
| 115 | readVaultObject: VaultObjectReader, | 127 | readVaultObject: VaultObjectReader, |
| 116 | ): Promise<CredentialPayload | null> { | 128 | ): Promise<Result<CredentialPayload | null, Error>> { |
| 117 | const parsedConfig = workosVaultSecretConfigSchema.safeParse(asRecord(record.secretJson)); | 129 | const parsedReference = parseWorkosVaultReference(coerceRecord(record.secretJson)); |
| 118 | const config = parsedConfig.success ? parsedConfig.data : {}; | 130 | const objectId = parsedReference.objectId ?? ""; |
| 119 | const objectId = config.objectId?.trim() || config.id?.trim() || ""; | ||
| 120 | 131 | ||
| 121 | if (!objectId) { | 132 | if (!objectId) { |
| 122 | throw new Error( | 133 | return Result.err( |
| 123 | "Encrypted credential is missing its secure reference. Re-save this credential in the dashboard.", | 134 | new Error("Encrypted credential is missing its secure reference. Re-save this credential in the dashboard."), |
| 124 | ); | 135 | ); |
| 125 | } | 136 | } |
| 126 | const apiKey = config.apiKey?.trim() || undefined; | 137 | const apiKey = parsedReference.apiKey; |
| 127 | 138 | ||
| 128 | const raw = await readVaultObject({ objectId, apiKey }); | 139 | const rawResult = await Result.tryPromise(async () => { |
| 129 | return parseSecretValue(raw); | 140 | return await readVaultObject({ objectId, apiKey }); |
| 141 | }); | ||
| 142 | if (rawResult.isErr()) { | ||
| 143 | return Result.err(new Error(rawResult.error.message)); | ||
| 144 | } | ||
| 145 | |||
| 146 | return Result.ok(parseSecretValue(rawResult.value)); | ||
| 130 | } | 147 | } |
| 131 | 148 | ||
| 132 | const providers: Record<CredentialProvider, ProviderResolver> = { | 149 | const providers: Record<CredentialProvider, ProviderResolver> = { |
| @@ -138,10 +155,22 @@ export async function resolveCredentialPayload( · 5 unmodified lines | |||
| 138 | record: Pick<CredentialRecord, "provider" | "secretJson">, | 155 | record: Pick<CredentialRecord, "provider" | "secretJson">, |
| 139 | options?: { readVaultObject?: VaultObjectReader }, | 156 | options?: { readVaultObject?: VaultObjectReader }, |
| 140 | ): Promise<CredentialPayload | null> { | 157 | ): Promise<CredentialPayload | null> { |
| 158 | const resolved = await resolveCredentialPayloadResult(record, options); | ||
| 159 | if (resolved.isErr()) { | ||
| 160 | throw resolved.error; | ||
| 161 | } | ||
| 162 | |||
| 163 | return resolved.value; | ||
| 164 | } | ||
| 165 | |||
| 166 | export async function resolveCredentialPayloadResult( | ||
| 167 | record: Pick<CredentialRecord, "provider" | "secretJson">, | ||
| 168 | options?: { readVaultObject?: VaultObjectReader }, | ||
| 169 | ): Promise<Result<CredentialPayload | null, Error>> { | ||
| 141 | const provider = record.provider; | 170 | const provider = record.provider; |
| 142 | const resolver = providers[provider]; | 171 | const resolver = providers[provider]; |
| 143 | if (!resolver) { | 172 | if (!resolver) { |
| 144 | throw new Error(`Unsupported credential provider: ${provider}`); | 173 | return Result.err(new Error(`Unsupported credential provider: ${provider}`)); |
| 145 | } | 174 | } |
| 146 | 175 | ||
| 147 | return await resolver(record, options?.readVaultObject ?? defaultReadVaultObject); | 176 | return await resolver(record, options?.readVaultObject ?? defaultReadVaultObject); |
| @@ -0,0 +1,64 @@ | |||
| 1 | import { expect, test } from "bun:test"; | ||
| 2 | import { | ||
| 3 | extractWorkosVaultObjectId, | ||
| 4 | isWorkosVaultRetryableError, | ||
| 5 | parseWorkosVaultReference, | ||
| 6 | withWorkosVaultRetryResult, | ||
| 7 | } from "./workos-vault"; | ||
| 8 | |||
| 9 | test("parseWorkosVaultReference normalizes object id aliases", () => { | ||
| 10 | expect(parseWorkosVaultReference({ objectId: " secret_123 " })).toEqual({ objectId: "secret_123" }); | ||
| 11 | expect(parseWorkosVaultReference({ id: "secret_456", apiKey: " key_abc " })).toEqual({ | ||
| 12 | objectId: "secret_456", | ||
| 13 | apiKey: "key_abc", | ||
| 14 | }); | ||
| 15 | expect(parseWorkosVaultReference("not-an-object")).toEqual({}); | ||
| 16 | }); | ||
| 17 | |||
| 18 | test("extractWorkosVaultObjectId returns null when missing", () => { | ||
| 19 | expect(extractWorkosVaultObjectId({ apiKey: "key" })).toBeNull(); | ||
| 20 | expect(extractWorkosVaultObjectId({ id: "secret_789" })).toBe("secret_789"); | ||
| 21 | }); | ||
| 22 | |||
| 23 | test("isWorkosVaultRetryableError detects retryable vault messages", () => { | ||
| 24 | expect(isWorkosVaultRetryableError(new Error("KEK not yet ready"))).toBe(true); | ||
| 25 | expect(isWorkosVaultRetryableError(new Error("This can be retried"))).toBe(true); | ||
| 26 | expect(isWorkosVaultRetryableError(new Error("Permission denied"))).toBe(false); | ||
| 27 | }); | ||
| 28 | |||
| 29 | test("withWorkosVaultRetryResult retries retryable errors and succeeds", async () => { | ||
| 30 | let attempts = 0; | ||
| 31 | const result = await withWorkosVaultRetryResult(async () => { | ||
| 32 | attempts += 1; | ||
| 33 | if (attempts < 3) { | ||
| 34 | throw new Error("KEK not yet ready"); | ||
| 35 | } | ||
| 36 | return "ok"; | ||
| 37 | }, { | ||
| 38 | maxAttempts: 5, | ||
| 39 | initialDelayMs: 0, | ||
| 40 | maxDelayMs: 1, | ||
| 41 | }); | ||
| 42 | |||
| 43 | expect(result.isOk()).toBe(true); | ||
| 44 | if (result.isOk()) { | ||
| 45 | expect(result.value).toBe("ok"); | ||
| 46 | } | ||
| 47 | expect(attempts).toBe(3); | ||
| 48 | }); | ||
| 49 | |||
| 50 | test("withWorkosVaultRetryResult returns configured exhaustion error", async () => { | ||
| 51 | const result = await withWorkosVaultRetryResult(async () => { | ||
| 52 | throw new Error("can be retried"); | ||
| 53 | }, { | ||
| 54 | maxAttempts: 2, | ||
| 55 | initialDelayMs: 0, | ||
| 56 | maxDelayMs: 1, | ||
| 57 | exhaustionErrorMessage: "still warming up", | ||
| 58 | }); | ||
| 59 | |||
| 60 | expect(result.isErr()).toBe(true); | ||
| 61 | if (result.isErr()) { | ||
| 62 | expect(result.error.message).toBe("still warming up"); | ||
| 63 | } | ||
| 64 | }); | ||
| @@ -0,0 +1,128 @@ | |||
| 1 | "use node"; | ||
| 2 | |||
| 3 | import { WorkOS } from "@workos-inc/node"; | ||
| 4 | import { Result } from "better-result"; | ||
| 5 | import { z } from "zod"; | ||
| 6 | |||
| 7 | const workosVaultReferenceSchema = z.object({ | ||
| 8 | objectId: z.string().optional(), | ||
| 9 | id: z.string().optional(), | ||
| 10 | apiKey: z.string().optional(), | ||
| 11 | }); | ||
| 12 | |||
| 13 | function normalizeUnknownError(error: unknown): Error { | ||
| 14 | return error instanceof Error ? error : new Error(String(error)); | ||
| 15 | } | ||
| 16 | |||
| 17 | function normalizeDelayMs(value: number, fallback: number): number { | ||
| 18 | if (!Number.isFinite(value) || value < 0) { | ||
| 19 | return fallback; | ||
| 20 | } | ||
| 21 | return Math.floor(value); | ||
| 22 | } | ||
| 23 | |||
| 24 | function normalizeAttempts(value: number, fallback: number): number { | ||
| 25 | if (!Number.isFinite(value) || value < 1) { | ||
| 26 | return fallback; | ||
| 27 | } | ||
| 28 | return Math.floor(value); | ||
| 29 | } | ||
| 30 | |||
| 31 | export function parseWorkosVaultReference(value: unknown): { | ||
| 32 | objectId?: string; | ||
| 33 | apiKey?: string; | ||
| 34 | } { | ||
| 35 | const parsed = workosVaultReferenceSchema.safeParse(value); | ||
| 36 | if (!parsed.success) { | ||
| 37 | return {}; | ||
| 38 | } | ||
| 39 | |||
| 40 | const objectId = (parsed.data.objectId ?? parsed.data.id ?? "").trim(); | ||
| 41 | const apiKey = (parsed.data.apiKey ?? "").trim(); | ||
| 42 | |||
| 43 | return { | ||
| 44 | ...(objectId ? { objectId } : {}), | ||
| 45 | ...(apiKey ? { apiKey } : {}), | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | |||
| 49 | export function extractWorkosVaultObjectId(value: unknown): string | null { | ||
| 50 | const parsed = parseWorkosVaultReference(value); | ||
| 51 | return parsed.objectId ?? null; | ||
| 52 | } | ||
| 53 | |||
| 54 | export function resolveWorkosApiKey(explicitApiKey?: string): Result<string, Error> { | ||
| 55 | const candidate = explicitApiKey?.trim() || process.env.WORKOS_API_KEY?.trim() || ""; | ||
| 56 | if (!candidate) { | ||
| 57 | return Result.err(new Error("Encrypted storage requires WORKOS_API_KEY")); | ||
| 58 | } | ||
| 59 | |||
| 60 | return Result.ok(candidate); | ||
| 61 | } | ||
| 62 | |||
| 63 | export function createWorkosClient(explicitApiKey?: string): Result<WorkOS, Error> { | ||
| 64 | const apiKeyResult = resolveWorkosApiKey(explicitApiKey); | ||
| 65 | if (apiKeyResult.isErr()) { | ||
| 66 | return apiKeyResult; | ||
| 67 | } | ||
| 68 | |||
| 69 | return Result.ok(new WorkOS(apiKeyResult.value)); | ||
| 70 | } | ||
| 71 | |||
| 72 | export function isWorkosVaultRetryableError(error: unknown): boolean { | ||
| 73 | if (!(error instanceof Error)) { | ||
| 74 | return false; | ||
| 75 | } | ||
| 76 | |||
| 77 | const message = error.message.toLowerCase(); | ||
| 78 | return ( | ||
| 79 | message.includes("not yet ready") | ||
| 80 | || message.includes("can be retried") | ||
| 81 | || (message.includes("kek") && message.includes("ready")) | ||
| 82 | ); | ||
| 83 | } | ||
| 84 | |||
| 85 | async function sleep(ms: number): Promise<void> { | ||
| 86 | await new Promise((resolve) => setTimeout(resolve, ms)); | ||
| 87 | } | ||
| 88 | |||
| 89 | export interface WorkosVaultRetryOptions { | ||
| 90 | maxAttempts?: number; | ||
| 91 | initialDelayMs?: number; | ||
| 92 | maxDelayMs?: number; | ||
| 93 | exhaustionErrorMessage?: string; | ||
| 94 | } | ||
| 95 | |||
| 96 | export async function withWorkosVaultRetryResult<T>( | ||
| 97 | operation: () => Promise<T>, | ||
| 98 | options: WorkosVaultRetryOptions = {}, | ||
| 99 | ): Promise<Result<T, Error>> { | ||
| 100 | const maxAttempts = normalizeAttempts(options.maxAttempts ?? 5, 5); | ||
| 101 | const maxDelayMs = normalizeDelayMs(options.maxDelayMs ?? 10_000, 10_000); | ||
| 102 | let delayMs = normalizeDelayMs(options.initialDelayMs ?? 250, 250); | ||
| 103 | |||
| 104 | for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { | ||
| 105 | const valueResult = await Result.tryPromise(operation); | ||
| 106 | if (valueResult.isOk()) { | ||
| 107 | return valueResult; | ||
| 108 | } | ||
| 109 | |||
| 110 | const cause = valueResult.error.cause; | ||
| 111 | const normalizedError = normalizeUnknownError(cause); | ||
| 112 | if (!isWorkosVaultRetryableError(normalizedError)) { | ||
| 113 | return Result.err(normalizedError); | ||
| 114 | } | ||
| 115 | |||
| 116 | if (attempt === maxAttempts) { | ||
| 117 | if (options.exhaustionErrorMessage) { | ||
| 118 | return Result.err(new Error(options.exhaustionErrorMessage)); | ||
| 119 | } | ||
| 120 | return Result.err(normalizedError); | ||
| 121 | } | ||
| 122 | |||
| 123 | await sleep(delayMs); | ||
| 124 | delayMs = Math.min(Math.max(0, delayMs * 2), maxDelayMs); | ||
| 125 | } | ||
| 126 | |||
| 127 | return Result.err(new Error("WorkOS Vault retry loop exhausted unexpectedly")); | ||
| 128 | } | ||
| @@ -1,5 +1,12 @@ | |||
| 1 | import type { GraphqlExecutionEnvelope } from "../tool/source-execution"; | 1 | import type { GraphqlExecutionEnvelope } from "../tool/source-execution"; |
| 2 | import { asRecord } from "../utils"; | 2 | import { z } from "zod"; |
| 3 | |||
| 4 | const recordSchema = z.record(z.unknown()); | ||
| 5 | |||
| 6 | function coerceRecord(value: unknown): Record<string, unknown> { | ||
| 7 | const parsed = recordSchema.safeParse(value); | ||
| 8 | return parsed.success ? parsed.data : {}; | ||
| 9 | } | ||
| 3 | 10 | ||
| 4 | export interface GqlTypeRef { | 11 | export interface GqlTypeRef { |
| 5 | kind: string; | 12 | kind: string; |
| @@ -49,7 +56,7 @@ export function selectGraphqlFieldEnvelope( · 43 unmodified lines | |||
| 49 | envelope: GraphqlExecutionEnvelope, | 56 | envelope: GraphqlExecutionEnvelope, |
| 50 | operationName: string, | 57 | operationName: string, |
| 51 | ): GraphqlExecutionEnvelope { | 58 | ): GraphqlExecutionEnvelope { |
| 52 | const record = asRecord(envelope.data); | 59 | const record = coerceRecord(envelope.data); |
| 53 | if (Object.prototype.hasOwnProperty.call(record, operationName)) { | 60 | if (Object.prototype.hasOwnProperty.call(record, operationName)) { |
| 54 | return { | 61 | return { |
| 55 | data: record[operationName], | 62 | data: record[operationName], |
| @@ -82,7 +89,7 @@ export function normalizeGraphqlFieldVariables( · 26 unmodified lines | |||
| 82 | && typeof value === "object" | 89 | && typeof value === "object" |
| 83 | && !Array.isArray(value) | 90 | && !Array.isArray(value) |
| 84 | ) { | 91 | ) { |
| 85 | const nested = asRecord(value); | 92 | const nested = coerceRecord(value); |
| 86 | if (Object.keys(nested).length === 1 && Object.prototype.hasOwnProperty.call(nested, argName)) { | 93 | if (Object.keys(nested).length === 1 && Object.prototype.hasOwnProperty.call(nested, argName)) { |
| 87 | return { [argName]: nested[argName] }; | 94 | return { [argName]: nested[argName] }; |
| 88 | } | 95 | } |
| @@ -82,9 +82,10 @@ async function getConfiguredFunctionsPath(candidate: string): Promise<string | n · 81 unmodified lines | |||
| 82 | try { | 82 | try { |
| 83 | const raw = await fs.readFile(path.join(candidate, "convex.json"), "utf8"); | 83 | const raw = await fs.readFile(path.join(candidate, "convex.json"), "utf8"); |
| 84 | const parsed = convexJsonSchema.safeParse(JSON.parse(raw)); | 84 | const parsed = convexJsonSchema.safeParse(JSON.parse(raw)); |
| 85 | if (!parsed.success || typeof parsed.data.functions !== "string") { | 85 | if (!parsed.success || !parsed.data.functions) { |
| 86 | return null; | 86 | return null; |
| 87 | } | 87 | } |
| 88 | |||
| 88 | const value = parsed.data.functions.trim(); | 89 | const value = parsed.data.functions.trim(); |
| 89 | return value.length > 0 ? value : null; | 90 | return value.length > 0 ? value : null; |
| 90 | } catch { | 91 | } catch { |
| @@ -1,7 +1,6 @@ | |||
| 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 | import { z } from "zod"; |
| 6 | 5 | ||
| 7 | import type { ManagedRuntimeConfig, ManagedRuntimeInfo } from "../managed-runtime"; | 6 | import type { ManagedRuntimeConfig, ManagedRuntimeInfo } from "../managed-runtime"; |
| @@ -151,10 +150,13 @@ export function runtimeInfo(): ManagedRuntimeInfo { · 143 unmodified lines | |||
| 151 | export async function ensureConfig(info: ManagedRuntimeInfo): Promise<ManagedRuntimeConfig> { | 150 | export async function ensureConfig(info: ManagedRuntimeInfo): Promise<ManagedRuntimeConfig> { |
| 152 | if (await pathExists(info.configPath)) { | 151 | if (await pathExists(info.configPath)) { |
| 153 | const raw = await fs.readFile(info.configPath, "utf8"); | 152 | const raw = await fs.readFile(info.configPath, "utf8"); |
| 154 | const parsedResult = Result.try(() => JSON.parse(raw)); | 153 | let parsedValue: unknown; |
| 155 | const parsed = parsedResult.isOk() | 154 | try { |
| 156 | ? parseManagedRuntimeConfig(parsedResult.value) | 155 | parsedValue = JSON.parse(raw); |
| 157 | : null; | 156 | } catch { |
| 157 | parsedValue = null; | ||
| 158 | } | ||
| 159 | const parsed = parseManagedRuntimeConfig(parsedValue); | ||
| 158 | 160 | ||
| 159 | if (!parsed) { | 161 | if (!parsed) { |
| 160 | const config = defaultConfig(); | 162 | const config = defaultConfig(); |
| @@ -9,7 +9,6 @@ import type { · 8 unmodified lines | |||
| 9 | McpExecutorService, | 9 | McpExecutorService, |
| 10 | } from "./server-contracts"; | 10 | } from "./server-contracts"; |
| 11 | import { getTaskTerminalState } from "./server-utils"; | 11 | import { getTaskTerminalState } from "./server-utils"; |
| 12 | import { asRecord } from "../utils"; | ||
| 13 | 12 | ||
| 14 | const elicitationResponseSchema = z.object({ | 13 | const elicitationResponseSchema = z.object({ |
| 15 | action: z.enum(["accept", "decline", "cancel"]), | 14 | action: z.enum(["accept", "decline", "cancel"]), |
| @@ -19,6 +18,11 @@ const elicitationResponseSchema = z.object({ · 3 unmodified lines | |||
| 19 | }).optional(), | 18 | }).optional(), |
| 20 | }); | 19 | }); |
| 21 | 20 | ||
| 21 | const subscriptionEventPayloadSchema = z.object({ | ||
| 22 | status: z.string().optional(), | ||
| 23 | pendingApprovalCount: z.coerce.number().optional(), | ||
| 24 | }); | ||
| 25 | |||
| 22 | function formatApprovalInput(input: unknown, maxLength = 2000): string { | 26 | function formatApprovalInput(input: unknown, maxLength = 2000): string { |
| 23 | let serialized: string; | 27 | let serialized: string; |
| 24 | try { | 28 | try { |
| @@ -88,7 +92,7 @@ export function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt { · 63 unmodified lines | |||
| 88 | } | 92 | } |
| 89 | 93 | ||
| 90 | const action = parsedResponse.data.action; | 94 | const action = parsedResponse.data.action; |
| 91 | const content = asRecord(parsedResponse.data.content); | 95 | const content = parsedResponse.data.content; |
| 92 | 96 | ||
| 93 | if (action !== "accept") { | 97 | if (action !== "accept") { |
| 94 | return { | 98 | return { |
| @@ -99,12 +103,10 @@ export function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt { · 4 unmodified lines | |||
| 99 | }; | 103 | }; |
| 100 | } | 104 | } |
| 101 | 105 | ||
| 102 | const selectedDecision = content.decision; | 106 | const selectedDecision = content?.decision; |
| 103 | const decision = selectedDecision === "approved" ? "approved" : "denied"; | 107 | const decision = selectedDecision === "approved" ? "approved" : "denied"; |
| 104 | const selectedReason = content.reason; | 108 | const selectedReason = content?.reason?.trim(); |
| 105 | const reason = typeof selectedReason === "string" && selectedReason.trim().length > 0 | 109 | const reason = selectedReason && selectedReason.length > 0 ? selectedReason : undefined; |
| 106 | ? selectedReason | ||
| 107 | : undefined; | ||
| 108 | 110 | ||
| 109 | return { decision, reason }; | 111 | return { decision, reason }; |
| 110 | }; | 112 | }; |
| @@ -209,13 +211,13 @@ export function waitForTerminalTask( · 98 unmodified lines | |||
| 209 | }; | 211 | }; |
| 210 | 212 | ||
| 211 | unsubscribe = service.subscribe(taskId, workspaceId, (event) => { | 213 | unsubscribe = service.subscribe(taskId, workspaceId, (event) => { |
| 212 | const payload = asRecord(event.payload); | 214 | const parsedPayload = subscriptionEventPayloadSchema.safeParse(event.payload); |
| 213 | const type = typeof payload.status === "string" ? payload.status : undefined; | 215 | const type = parsedPayload.success ? parsedPayload.data.status : undefined; |
| 214 | const pendingApprovalCount = typeof payload.pendingApprovalCount === "number" | 216 | const pendingApprovalCount = parsedPayload.success |
| 215 | ? payload.pendingApprovalCount | 217 | ? (parsedPayload.data.pendingApprovalCount ?? 0) |
| 216 | : 0; | 218 | : 0; |
| 217 | 219 | ||
| 218 | if (typeof type === "string" && getTaskTerminalState(type)) { | 220 | if (type && getTaskTerminalState(type)) { |
| 219 | void done(); | 221 | void done(); |
| 220 | return; | 222 | return; |
| 221 | } | 223 | } |
| @@ -1,6 +1,10 @@ | |||
| 1 | import type { OpenApiAuth } from "./tool/source-types"; | 1 | import type { OpenApiAuth } from "./tool/source-types"; |
| 2 | import { z } from "zod"; | 2 | import { z } from "zod"; |
| 3 | import { asRecord } from "./utils"; | 3 | import { toPlainObject } from "./utils"; |
| 4 | |||
| 5 | function toRecordOrEmpty(value: unknown): Record<string, unknown> { | ||
| 6 | return toPlainObject(value) ?? {}; | ||
| 7 | } | ||
| 4 | 8 | ||
| 5 | const securityRequirementSchema = z.record(z.array(z.unknown()).optional()); | 9 | const securityRequirementSchema = z.record(z.array(z.unknown()).optional()); |
| 6 | 10 | ||
| @@ -12,8 +16,8 @@ const securitySchemeSchema = z.object({ · 5 unmodified lines | |||
| 12 | }); | 16 | }); |
| 13 | 17 | ||
| 14 | export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | undefined { | 18 | export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | undefined { |
| 15 | const components = asRecord(spec.components); | 19 | const components = toRecordOrEmpty(spec.components); |
| 16 | const securitySchemes = asRecord(components.securitySchemes); | 20 | const securitySchemes = toRecordOrEmpty(components.securitySchemes); |
| 17 | if (Object.keys(securitySchemes).length === 0) { | 21 | if (Object.keys(securitySchemes).length === 0) { |
| 18 | return undefined; | 22 | return undefined; |
| 19 | } | 23 | } |
| @@ -11,7 +11,11 @@ import { · 10 unmodified lines | |||
| 11 | responseTypeHintFromSchema, | 11 | responseTypeHintFromSchema, |
| 12 | type OpenApiParameterHint, | 12 | type OpenApiParameterHint, |
| 13 | } from "./openapi/schema-hints"; | 13 | } from "./openapi/schema-hints"; |
| 14 | import { asRecord } from "./utils"; | 14 | import { toPlainObject } from "./utils"; |
| 15 | |||
| 16 | function toRecordOrEmpty(value: unknown): Record<string, unknown> { | ||
| 17 | return toPlainObject(value) ?? {}; | ||
| 18 | } | ||
| 15 | 19 | ||
| 16 | export interface CompactOpenApiPathsOptions { | 20 | export interface CompactOpenApiPathsOptions { |
| 17 | includeSchemas?: boolean; | 21 | includeSchemas?: boolean; |
| @@ -28,13 +32,13 @@ export function compactOpenApiPaths( · 10 unmodified lines | |||
| 28 | componentRequestBodies?: Record<string, unknown>, | 32 | componentRequestBodies?: Record<string, unknown>, |
| 29 | options: CompactOpenApiPathsOptions = {}, | 33 | options: CompactOpenApiPathsOptions = {}, |
| 30 | ): Record<string, unknown> { | 34 | ): Record<string, unknown> { |
| 31 | const paths = asRecord(pathsValue); | 35 | const paths = toRecordOrEmpty(pathsValue); |
| 32 | const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const; | 36 | const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const; |
| 33 | const compactPaths: Record<string, unknown> = {}; | 37 | const compactPaths: Record<string, unknown> = {}; |
| 34 | const compParams = componentParameters ? asRecord(componentParameters) : {}; | 38 | const compParams = componentParameters ? toRecordOrEmpty(componentParameters) : {}; |
| 35 | const compSchemas = componentSchemas ? asRecord(componentSchemas) : {}; | 39 | const compSchemas = componentSchemas ? toRecordOrEmpty(componentSchemas) : {}; |
| 36 | const compResponses = componentResponses ? asRecord(componentResponses) : {}; | 40 | const compResponses = componentResponses ? toRecordOrEmpty(componentResponses) : {}; |
| 37 | const compRequestBodies = componentRequestBodies ? asRecord(componentRequestBodies) : {}; | 41 | const compRequestBodies = componentRequestBodies ? toRecordOrEmpty(componentRequestBodies) : {}; |
| 38 | const includeSchemas = options.includeSchemas ?? true; | 42 | const includeSchemas = options.includeSchemas ?? true; |
| 39 | const includeTypeHints = options.includeTypeHints ?? true; | 43 | const includeTypeHints = options.includeTypeHints ?? true; |
| 40 | const includeParameterSchemas = options.includeParameterSchemas ?? true; | 44 | const includeParameterSchemas = options.includeParameterSchemas ?? true; |
| @@ -45,7 +49,7 @@ export function compactOpenApiPaths( · 4 unmodified lines | |||
| 45 | const prefix = "#/components/parameters/"; | 49 | const prefix = "#/components/parameters/"; |
| 46 | if (ref.startsWith(prefix)) { | 50 | if (ref.startsWith(prefix)) { |
| 47 | const key = ref.slice(prefix.length); | 51 | const key = ref.slice(prefix.length); |
| 48 | const resolved = asRecord(compParams[key]); | 52 | const resolved = toRecordOrEmpty(compParams[key]); |
| 49 | if (Object.keys(resolved).length > 0) return resolved; | 53 | if (Object.keys(resolved).length > 0) return resolved; |
| 50 | } | 54 | } |
| 51 | } | 55 | } |
| @@ -55,7 +59,7 @@ export function compactOpenApiPaths( · 3 unmodified lines | |||
| 55 | const normalizeParameters = (entries: unknown): Array<OpenApiParameterHint & { in: string }> => { | 59 | const normalizeParameters = (entries: unknown): Array<OpenApiParameterHint & { in: string }> => { |
| 56 | if (!Array.isArray(entries)) return []; | 60 | if (!Array.isArray(entries)) return []; |
| 57 | return entries | 61 | return entries |
| 58 | .map((entry) => resolveParam(asRecord(entry))) | 62 | .map((entry) => resolveParam(toRecordOrEmpty(entry))) |
| 59 | .filter((entry) => typeof entry.name === "string" && typeof entry.in === "string") | 63 | .filter((entry) => typeof entry.name === "string" && typeof entry.in === "string") |
| 60 | .map((entry) => ({ | 64 | .map((entry) => ({ |
| 61 | name: String(entry.name), | 65 | name: String(entry.name), |
| @@ -66,7 +70,7 @@ export function compactOpenApiPaths( · 4 unmodified lines | |||
| 66 | }; | 70 | }; |
| 67 | 71 | ||
| 68 | for (const [pathTemplate, pathValue] of Object.entries(paths)) { | 72 | for (const [pathTemplate, pathValue] of Object.entries(paths)) { |
| 69 | const pathObject = asRecord(pathValue); | 73 | const pathObject = toRecordOrEmpty(pathValue); |
| 70 | const compactPathObject: Record<string, unknown> = {}; | 74 | const compactPathObject: Record<string, unknown> = {}; |
| 71 | const sharedParameters = normalizeParameters(pathObject.parameters); | 75 | const sharedParameters = normalizeParameters(pathObject.parameters); |
| 72 | if (sharedParameters.length > 0) { | 76 | if (sharedParameters.length > 0) { |
| @@ -74,7 +78,7 @@ export function compactOpenApiPaths( · 1 unmodified line | |||
| 74 | } | 78 | } |
| 75 | 79 | ||
| 76 | for (const method of methods) { | 80 | for (const method of methods) { |
| 77 | const operation = asRecord(pathObject[method]); | 81 | const operation = toRecordOrEmpty(pathObject[method]); |
| 78 | if (Object.keys(operation).length === 0) continue; | 82 | if (Object.keys(operation).length === 0) continue; |
| 79 | 83 | ||
| 80 | const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`); | 84 | const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`); |
| @@ -106,16 +110,16 @@ export function compactOpenApiPaths( · 25 unmodified lines | |||
| 106 | // Always attempt to compute minimal input/output schemas. This keeps the | 110 | // Always attempt to compute minimal input/output schemas. This keeps the |
| 107 | // prepared spec compact while enabling schema-first tool signatures. | 111 | // prepared spec compact while enabling schema-first tool signatures. |
| 108 | if (includeSchemas || hasGeneratedTypes) { | 112 | if (includeSchemas || hasGeneratedTypes) { |
| 109 | const requestBody = resolveRequestBodyRef(asRecord(operation.requestBody), compRequestBodies); | 113 | const requestBody = resolveRequestBodyRef(toRecordOrEmpty(operation.requestBody), compRequestBodies); |
| 110 | const requestBodyContent = asRecord(requestBody.content); | 114 | const requestBodyContent = toRecordOrEmpty(requestBody.content); |
| 111 | const rawRequestBodySchema = getPreferredContentSchema(requestBodyContent); | 115 | const rawRequestBodySchema = getPreferredContentSchema(requestBodyContent); |
| 112 | requestBodySchema = resolveSchemaRef(rawRequestBodySchema, compSchemas); | 116 | requestBodySchema = resolveSchemaRef(rawRequestBodySchema, compSchemas); |
| 113 | 117 | ||
| 114 | const responses = asRecord(operation.responses); | 118 | const responses = toRecordOrEmpty(operation.responses); |
| 115 | for (const [status, responseValue] of Object.entries(responses)) { | 119 | for (const [status, responseValue] of Object.entries(responses)) { |
| 116 | if (!status.startsWith("2")) continue; | 120 | if (!status.startsWith("2")) continue; |
| 117 | responseStatus = status; | 121 | responseStatus = status; |
| 118 | const resolvedResponse = resolveResponseRef(asRecord(responseValue), compResponses); | 122 | const resolvedResponse = resolveResponseRef(toRecordOrEmpty(responseValue), compResponses); |
| 119 | responseSchema = resolveSchemaRef( | 123 | responseSchema = resolveSchemaRef( |
| 120 | getPreferredResponseSchema(resolvedResponse), | 124 | getPreferredResponseSchema(resolvedResponse), |
| 121 | compSchemas, | 125 | compSchemas, |
| @@ -6,10 +6,15 @@ import { inferOpenApiAuth } from "./openapi-auth"; · 5 unmodified lines | |||
| 6 | import { compactOpenApiPaths } from "./openapi-compaction"; | 6 | import { compactOpenApiPaths } from "./openapi-compaction"; |
| 7 | import { extractOperationIdsFromDts } from "./openapi/schema-hints"; | 7 | import { extractOperationIdsFromDts } from "./openapi/schema-hints"; |
| 8 | import type { PreparedOpenApiSpec } from "./tool/source-types"; | 8 | import type { PreparedOpenApiSpec } from "./tool/source-types"; |
| 9 | import { asRecord } from "./utils"; | 9 | import { toPlainObject } from "./utils"; |
| 10 | 10 | ||
| 11 | const unknownRecordSchema = z.record(z.unknown()); | 11 | const unknownRecordSchema = z.record(z.unknown()); |
| 12 | 12 | ||
| 13 | const openApiDocumentSchema = z.object({ | ||
| 14 | openapi: z.string().optional(), | ||
| 15 | swagger: z.string().optional(), | ||
| 16 | }).catchall(z.unknown()); | ||
| 17 | |||
| 13 | interface SwaggerParserAdapter { | 18 | interface SwaggerParserAdapter { |
| 14 | bundle(spec: unknown): Promise<unknown>; | 19 | bundle(spec: unknown): Promise<unknown>; |
| 15 | parse(spec: unknown): Promise<unknown>; | 20 | parse(spec: unknown): Promise<unknown>; |
| @@ -41,16 +46,13 @@ function toRecordResult(value: unknown, label: string): Result<Record<string, un · 25 unmodified lines | |||
| 41 | return Result.ok(parsed.data); | 46 | return Result.ok(parsed.data); |
| 42 | } | 47 | } |
| 43 | 48 | ||
| 44 | function describeResultError(error: unknown): string { | 49 | function toOpenApiTsInput(spec: Record<string, unknown>): Parameters<typeof openapiTS>[0] { |
| 45 | const cause = asRecord(error).cause; | 50 | const parsed = openApiDocumentSchema.safeParse(spec); |
| 46 | if (cause instanceof Error) { | 51 | if (!parsed.success) { |
| 47 | return cause.message; | 52 | throw new Error(`OpenAPI document is invalid: ${parsed.error.message}`); |
| 48 | } | ||
| 49 | if (typeof cause === "string" && cause.trim().length > 0) { | ||
| 50 | return cause; | ||
| 51 | } | 53 | } |
| 52 | 54 | ||
| 53 | return error instanceof Error ? error.message : String(error); | 55 | return parsed.data as Parameters<typeof openapiTS>[0] & Record<string, unknown>; |
| 54 | } | 56 | } |
| 55 | 57 | ||
| 56 | function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null { | 58 | function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string, unknown> | null { |
| @@ -62,7 +64,9 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 5 unmodified lines | |||
| 62 | let target: unknown = spec; | 64 | let target: unknown = spec; |
| 63 | for (const segment of segments) { | 65 | for (const segment of segments) { |
| 64 | if (target && typeof target === "object") { | 66 | if (target && typeof target === "object") { |
| 65 | target = asRecord(target)[segment]; | 67 | const targetRecord = toPlainObject(target); |
| 68 | if (!targetRecord) return false; | ||
| 69 | target = targetRecord[segment]; | ||
| 66 | } else { | 70 | } else { |
| 67 | return false; | 71 | return false; |
| 68 | } | 72 | } |
| @@ -73,11 +77,12 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 4 unmodified lines | |||
| 73 | function hasBrokenDiscriminators(obj: unknown): boolean { | 77 | function hasBrokenDiscriminators(obj: unknown): boolean { |
| 74 | if (Array.isArray(obj)) return obj.some(hasBrokenDiscriminators); | 78 | if (Array.isArray(obj)) return obj.some(hasBrokenDiscriminators); |
| 75 | if (obj && typeof obj === "object") { | 79 | if (obj && typeof obj === "object") { |
| 76 | const record = asRecord(obj); | 80 | const record = toPlainObject(obj); |
| 81 | if (!record) return false; | ||
| 77 | if (record.discriminator && typeof record.discriminator === "object") { | 82 | if (record.discriminator && typeof record.discriminator === "object") { |
| 78 | const disc = asRecord(record.discriminator); | 83 | const disc = toPlainObject(record.discriminator) ?? {}; |
| 79 | if (disc.mapping && typeof disc.mapping === "object") { | 84 | if (disc.mapping && typeof disc.mapping === "object") { |
| 80 | const mapping = asRecord(disc.mapping); | 85 | const mapping = toPlainObject(disc.mapping) ?? {}; |
| 81 | if (Object.values(mapping).some((ref) => typeof ref === "string" && !refExists(ref))) { | 86 | if (Object.values(mapping).some((ref) => typeof ref === "string" && !refExists(ref))) { |
| 82 | return true; | 87 | return true; |
| 83 | } | 88 | } |
| @@ -93,13 +98,14 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 9 unmodified lines | |||
| 93 | function walk(obj: unknown): unknown { | 98 | function walk(obj: unknown): unknown { |
| 94 | if (Array.isArray(obj)) return obj.map(walk); | 99 | if (Array.isArray(obj)) return obj.map(walk); |
| 95 | if (obj && typeof obj === "object") { | 100 | if (obj && typeof obj === "object") { |
| 96 | const record = asRecord(obj); | 101 | const record = toPlainObject(obj); |
| 102 | if (!record) return obj; | ||
| 97 | const clone: Record<string, unknown> = {}; | 103 | const clone: Record<string, unknown> = {}; |
| 98 | for (const [key, value] of Object.entries(record)) { | 104 | for (const [key, value] of Object.entries(record)) { |
| 99 | if (key === "discriminator" && typeof value === "object" && value !== null) { | 105 | if (key === "discriminator" && typeof value === "object" && value !== null) { |
| 100 | const disc = asRecord(value); | 106 | const disc = toPlainObject(value) ?? {}; |
| 101 | if (disc.mapping && typeof disc.mapping === "object") { | 107 | if (disc.mapping && typeof disc.mapping === "object") { |
| 102 | const mapping = asRecord(disc.mapping); | 108 | const mapping = toPlainObject(disc.mapping) ?? {}; |
| 103 | const hasBroken = Object.values(mapping).some( | 109 | const hasBroken = Object.values(mapping).some( |
| 104 | (ref) => typeof ref === "string" && !refExists(ref), | 110 | (ref) => typeof ref === "string" && !refExists(ref), |
| 105 | ); | 111 | ); |
| @@ -127,7 +133,7 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 21 unmodified lines | |||
| 127 | 133 | ||
| 128 | async function generateOpenApiDts(spec: Record<string, unknown>): Promise<string | null> { | 134 | async function generateOpenApiDts(spec: Record<string, unknown>): Promise<string | null> { |
| 129 | try { | 135 | try { |
| 130 | const ast = await openapiTS(spec as never, { silent: true }); | 136 | const ast = await openapiTS(toOpenApiTsInput(spec), { silent: true }); |
| 131 | return astToString(ast); | 137 | return astToString(ast); |
| 132 | } catch (error) { | 138 | } catch (error) { |
| 133 | const msg = error instanceof Error ? error.message : String(error); | 139 | const msg = error instanceof Error ? error.message : String(error); |
| @@ -135,7 +141,7 @@ async function generateOpenApiDts(spec: Record<string, unknown>): Promise<string · 1 unmodified line | |||
| 135 | if (patched) { | 141 | if (patched) { |
| 136 | console.warn(`[executor] openapi-typescript failed, retrying with patched spec: ${msg}`); | 142 | console.warn(`[executor] openapi-typescript failed, retrying with patched spec: ${msg}`); |
| 137 | try { | 143 | try { |
| 138 | const ast = await openapiTS(patched as never, { silent: true }); | 144 | const ast = await openapiTS(toOpenApiTsInput(patched), { silent: true }); |
| 139 | return astToString(ast); | 145 | return astToString(ast); |
| 140 | } catch (retryError) { | 146 | } catch (retryError) { |
| 141 | const retryMsg = retryError instanceof Error ? retryError.message : String(retryError); | 147 | const retryMsg = retryError instanceof Error ? retryError.message : String(retryError); |
| @@ -168,13 +174,15 @@ export async function prepareOpenApiSpec( · 26 unmodified lines | |||
| 168 | 174 | ||
| 169 | let parsed: Record<string, unknown>; | 175 | let parsed: Record<string, unknown>; |
| 170 | if (typeof spec === "string") { | 176 | if (typeof spec === "string") { |
| 171 | const parsedResult = await Result.tryPromise(() => parser.parse(spec)); | 177 | let parsedValue: unknown; |
| 172 | if (parsedResult.isErr()) { | 178 | try { |
| 173 | const msg = describeResultError(parsedResult.error); | 179 | parsedValue = await parser.parse(spec); |
| 180 | } catch (error) { | ||
| 181 | const msg = error instanceof Error ? error.message : String(error); | ||
| 174 | throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`); | 182 | throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${msg}`); |
| 175 | } | 183 | } |
| 176 | 184 | ||
| 177 | const parsedRecord = toRecordResult(parsedResult.value, `Parsed OpenAPI source '${sourceName}'`); | 185 | const parsedRecord = toRecordResult(parsedValue, `Parsed OpenAPI source '${sourceName}'`); |
| 178 | if (parsedRecord.isErr()) { | 186 | if (parsedRecord.isErr()) { |
| 179 | throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${parsedRecord.error.message}`); | 187 | throw new Error(`Failed to fetch/parse OpenAPI source '${sourceName}': ${parsedRecord.error.message}`); |
| 180 | } | 188 | } |
| @@ -184,18 +192,23 @@ export async function prepareOpenApiSpec( · 3 unmodified lines | |||
| 184 | parsed = spec; | 192 | parsed = spec; |
| 185 | } | 193 | } |
| 186 | 194 | ||
| 187 | let bundled: Record<string, unknown>; | 195 | let bundled: Record<string, unknown> = parsed; |
| 188 | const dtsPromise = shouldGenerateDts | 196 | const dtsPromise = shouldGenerateDts |
| 189 | ? generateOpenApiDts(parsed) | 197 | ? generateOpenApiDts(parsed) |
| 190 | : Promise.resolve<string | null>(null); | 198 | : Promise.resolve<string | null>(null); |
| 191 | if (shouldBundle) { | 199 | if (shouldBundle) { |
| 192 | const bundleResult = await Result.tryPromise(() => parser.bundle(parsed)); | 200 | let bundledValue: unknown; |
| 193 | if (bundleResult.isErr()) { | 201 | try { |
| 194 | const bundleMessage = describeResultError(bundleResult.error); | 202 | bundledValue = await parser.bundle(parsed); |
| 203 | } catch (error) { | ||
| 204 | const bundleMessage = error instanceof Error ? error.message : String(error); | ||
| 195 | warnings.push(`OpenAPI bundle failed for '${sourceName}', using parse-only mode: ${bundleMessage}`); | 205 | warnings.push(`OpenAPI bundle failed for '${sourceName}', using parse-only mode: ${bundleMessage}`); |
| 196 | bundled = parsed; | 206 | bundled = parsed; |
| 197 | } else { | 207 | bundledValue = undefined; |
| 198 | const bundledRecord = toRecordResult(bundleResult.value, `Bundled OpenAPI source '${sourceName}'`); | 208 | } |
| 209 | |||
| 210 | if (bundledValue !== undefined) { | ||
| 211 | const bundledRecord = toRecordResult(bundledValue, `Bundled OpenAPI source '${sourceName}'`); | ||
| 199 | if (bundledRecord.isErr()) { | 212 | if (bundledRecord.isErr()) { |
| 200 | warnings.push(`OpenAPI bundle returned non-object payload for '${sourceName}', using parse-only mode`); | 213 | warnings.push(`OpenAPI bundle returned non-object payload for '${sourceName}', using parse-only mode`); |
| 201 | bundled = parsed; | 214 | bundled = parsed; |
| @@ -203,8 +216,6 @@ export async function prepareOpenApiSpec( · 1 unmodified line | |||
| 203 | bundled = bundledRecord.value; | 216 | bundled = bundledRecord.value; |
| 204 | } | 217 | } |
| 205 | } | 218 | } |
| 206 | } else { | ||
| 207 | bundled = parsed; | ||
| 208 | } | 219 | } |
| 209 | const dts = await dtsPromise; | 220 | const dts = await dtsPromise; |
| 210 | 221 | ||
| @@ -215,17 +226,17 @@ export async function prepareOpenApiSpec( · 4 unmodified lines | |||
| 215 | return { | 226 | return { |
| 216 | servers: servers | 227 | servers: servers |
| 217 | .map((server) => { | 228 | .map((server) => { |
| 218 | const value = asRecord(server).url; | 229 | const value = toPlainObject(server)?.url; |
| 219 | return typeof value === "string" ? value : ""; | 230 | return typeof value === "string" ? value : ""; |
| 220 | }) | 231 | }) |
| 221 | .filter((url) => url.length > 0), | 232 | .filter((url) => url.length > 0), |
| 222 | paths: compactOpenApiPaths( | 233 | paths: compactOpenApiPaths( |
| 223 | bundled.paths, | 234 | bundled.paths, |
| 224 | operationTypeIds, | 235 | operationTypeIds, |
| 225 | asRecord(asRecord(bundled.components).parameters), | 236 | toPlainObject(toPlainObject(bundled.components)?.parameters) ?? {}, |
| 226 | asRecord(asRecord(bundled.components).schemas), | 237 | toPlainObject(toPlainObject(bundled.components)?.schemas) ?? {}, |
| 227 | asRecord(asRecord(bundled.components).responses), | 238 | toPlainObject(toPlainObject(bundled.components)?.responses) ?? {}, |
| 228 | asRecord(asRecord(bundled.components).requestBodies), | 239 | toPlainObject(toPlainObject(bundled.components)?.requestBodies) ?? {}, |
| 229 | { | 240 | { |
| 230 | includeSchemas: profile === "full", | 241 | includeSchemas: profile === "full", |
| 231 | includeTypeHints: true, | 242 | includeTypeHints: true, |
| @@ -1,4 +1,8 @@ | |||
| 1 | import { asRecord } from "../utils"; | 1 | import { toPlainObject } from "../utils"; |
| 2 | |||
| 3 | function toRecordOrEmpty(value: unknown): Record<string, unknown> { | ||
| 4 | return toPlainObject(value) ?? {}; | ||
| 5 | } | ||
| 2 | 6 | ||
| 3 | type JsonSchema = Record<string, unknown>; | 7 | type JsonSchema = Record<string, unknown>; |
| 4 | const COMPONENT_REF_INLINE_DEPTH = 2; | 8 | const COMPONENT_REF_INLINE_DEPTH = 2; |
| @@ -6,7 +10,7 @@ const COMPONENT_REF_INLINE_DEPTH = 2; · 1 unmodified line | |||
| 6 | function isSmallInlineableComponentSchema(schema: Record<string, unknown>): boolean { | 10 | function isSmallInlineableComponentSchema(schema: Record<string, unknown>): boolean { |
| 7 | const shape = schema; | 11 | const shape = schema; |
| 8 | const type = typeof shape.type === "string" ? shape.type : undefined; | 12 | const type = typeof shape.type === "string" ? shape.type : undefined; |
| 9 | const props = asRecord(shape.properties); | 13 | const props = toRecordOrEmpty(shape.properties); |
| 10 | const propCount = Object.keys(props).length; | 14 | const propCount = Object.keys(props).length; |
| 11 | if (type !== "object" && propCount === 0) return false; | 15 | if (type !== "object" && propCount === 0) return false; |
| 12 | if (propCount === 0) return false; | 16 | if (propCount === 0) return false; |
| @@ -38,18 +42,18 @@ export function getPreferredContentSchema(content: Record<string, unknown>): Rec · 25 unmodified lines | |||
| 38 | const preferredKeys = ["application/json", "*/*"]; | 42 | const preferredKeys = ["application/json", "*/*"]; |
| 39 | 43 | ||
| 40 | for (const key of preferredKeys) { | 44 | for (const key of preferredKeys) { |
| 41 | const schema = asRecord(asRecord(content[key]).schema); | 45 | const schema = toRecordOrEmpty(toRecordOrEmpty(content[key]).schema); |
| 42 | if (Object.keys(schema).length > 0) return schema; | 46 | if (Object.keys(schema).length > 0) return schema; |
| 43 | } | 47 | } |
| 44 | 48 | ||
| 45 | for (const [key, value] of Object.entries(content)) { | 49 | for (const [key, value] of Object.entries(content)) { |
| 46 | if (!key.includes("json")) continue; | 50 | if (!key.includes("json")) continue; |
| 47 | const schema = asRecord(asRecord(value).schema); | 51 | const schema = toRecordOrEmpty(toRecordOrEmpty(value).schema); |
| 48 | if (Object.keys(schema).length > 0) return schema; | 52 | if (Object.keys(schema).length > 0) return schema; |
| 49 | } | 53 | } |
| 50 | 54 | ||
| 51 | for (const value of Object.values(content)) { | 55 | for (const value of Object.values(content)) { |
| 52 | const schema = asRecord(asRecord(value).schema); | 56 | const schema = toRecordOrEmpty(toRecordOrEmpty(value).schema); |
| 53 | if (Object.keys(schema).length > 0) return schema; | 57 | if (Object.keys(schema).length > 0) return schema; |
| 54 | } | 58 | } |
| 55 | 59 | ||
| @@ -57,12 +61,12 @@ export function getPreferredContentSchema(content: Record<string, unknown>): Rec · 1 unmodified line | |||
| 57 | } | 61 | } |
| 58 | 62 | ||
| 59 | export function getPreferredResponseSchema(responseValue: Record<string, unknown>): Record<string, unknown> { | 63 | export function getPreferredResponseSchema(responseValue: Record<string, unknown>): Record<string, unknown> { |
| 60 | const contentSchema = getPreferredContentSchema(asRecord(responseValue.content)); | 64 | const contentSchema = getPreferredContentSchema(toRecordOrEmpty(responseValue.content)); |
| 61 | if (Object.keys(contentSchema).length > 0) { | 65 | if (Object.keys(contentSchema).length > 0) { |
| 62 | return contentSchema; | 66 | return contentSchema; |
| 63 | } | 67 | } |
| 64 | 68 | ||
| 65 | const schema = asRecord(responseValue.schema); | 69 | const schema = toRecordOrEmpty(responseValue.schema); |
| 66 | if (Object.keys(schema).length > 0) { | 70 | if (Object.keys(schema).length > 0) { |
| 67 | return schema; | 71 | return schema; |
| 68 | } | 72 | } |
| @@ -81,7 +85,7 @@ export function resolveSchemaRef( · 12 unmodified lines | |||
| 81 | } | 85 | } |
| 82 | 86 | ||
| 83 | const key = ref.slice(prefix.length); | 87 | const key = ref.slice(prefix.length); |
| 84 | const resolved = asRecord(componentSchemas[key]); | 88 | const resolved = toRecordOrEmpty(componentSchemas[key]); |
| 85 | if (Object.keys(resolved).length === 0) { | 89 | if (Object.keys(resolved).length === 0) { |
| 86 | return schema; | 90 | return schema; |
| 87 | } | 91 | } |
| @@ -99,7 +103,7 @@ export function resolveRequestBodyRef( · 11 unmodified lines | |||
| 99 | } | 103 | } |
| 100 | 104 | ||
| 101 | const key = ref.slice(prefix.length); | 105 | const key = ref.slice(prefix.length); |
| 102 | const resolved = asRecord(componentRequestBodies[key]); | 106 | const resolved = toRecordOrEmpty(componentRequestBodies[key]); |
| 103 | if (Object.keys(resolved).length === 0) { | 107 | if (Object.keys(resolved).length === 0) { |
| 104 | return requestBody; | 108 | return requestBody; |
| 105 | } | 109 | } |
| @@ -117,7 +121,7 @@ export function resolveResponseRef( · 11 unmodified lines | |||
| 117 | } | 121 | } |
| 118 | 122 | ||
| 119 | const key = ref.slice(prefix.length); | 123 | const key = ref.slice(prefix.length); |
| 120 | const resolved = asRecord(componentResponses[key]); | 124 | const resolved = toRecordOrEmpty(componentResponses[key]); |
| 121 | if (Object.keys(resolved).length === 0) { | 125 | if (Object.keys(resolved).length === 0) { |
| 122 | return response; | 126 | return response; |
| 123 | } | 127 | } |
| @@ -125,7 +129,7 @@ export function resolveResponseRef( · 1 unmodified line | |||
| 125 | } | 129 | } |
| 126 | 130 | ||
| 127 | export function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string, unknown> { | 131 | export function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string, unknown> { |
| 128 | const schema = asRecord(entry.schema); | 132 | const schema = toRecordOrEmpty(entry.schema); |
| 129 | if (Object.keys(schema).length > 0) { | 133 | if (Object.keys(schema).length > 0) { |
| 130 | return schema; | 134 | return schema; |
| 131 | } | 135 | } |
| @@ -139,7 +143,7 @@ export function parameterSchemaFromEntry(entry: Record<string, unknown>): Record · 7 unmodified lines | |||
| 139 | if (Array.isArray(entry.enum) && entry.enum.length > 0) { | 143 | if (Array.isArray(entry.enum) && entry.enum.length > 0) { |
| 140 | fallback.enum = entry.enum; | 144 | fallback.enum = entry.enum; |
| 141 | } | 145 | } |
| 142 | const items = asRecord(entry.items); | 146 | const items = toRecordOrEmpty(entry.items); |
| 143 | if (Object.keys(items).length > 0) { | 147 | if (Object.keys(items).length > 0) { |
| 144 | fallback.items = items; | 148 | fallback.items = items; |
| 145 | } | 149 | } |
| @@ -239,13 +243,13 @@ function joinUnion(parts: string[]): string { · 93 unmodified lines | |||
| 239 | function isObjectSchema(schema: JsonSchema): boolean { | 243 | function isObjectSchema(schema: JsonSchema): boolean { |
| 240 | const type = typeof schema.type === "string" ? schema.type : undefined; | 244 | const type = typeof schema.type === "string" ? schema.type : undefined; |
| 241 | if (type === "object") return true; | 245 | if (type === "object") return true; |
| 242 | return Object.keys(asRecord(schema.properties)).length > 0; | 246 | return Object.keys(toRecordOrEmpty(schema.properties)).length > 0; |
| 243 | } | 247 | } |
| 244 | 248 | ||
| 245 | function isEmptyObjectSchema(schema: JsonSchema): boolean { | 249 | function isEmptyObjectSchema(schema: JsonSchema): boolean { |
| 246 | const type = typeof schema.type === "string" ? schema.type : undefined; | 250 | const type = typeof schema.type === "string" ? schema.type : undefined; |
| 247 | if (type !== "object") return false; | 251 | if (type !== "object") return false; |
| 248 | return Object.keys(asRecord(schema.properties)).length === 0; | 252 | return Object.keys(toRecordOrEmpty(schema.properties)).length === 0; |
| 249 | } | 253 | } |
| 250 | 254 | ||
| 251 | function isPlainScalarSchema(schema: JsonSchema): boolean { | 255 | function isPlainScalarSchema(schema: JsonSchema): boolean { |
| @@ -279,7 +283,7 @@ function normalizeUnionSchemaVariants(variants: JsonSchema[]): JsonSchema[] { · 27 unmodified lines | |||
| 279 | // Many real-world OpenAPI specs include `oneOf: [{type:"object"}, { ...specific... }]` | 283 | // Many real-world OpenAPI specs include `oneOf: [{type:"object"}, { ...specific... }]` |
| 280 | // or even `oneOf: [{type:"string"}, { ...object... }, { ...object... }]`. | 284 | // or even `oneOf: [{type:"string"}, { ...object... }, { ...object... }]`. |
| 281 | // These broad scalar/empty-object variants add noise without helping the UI/LLM. | 285 | // These broad scalar/empty-object variants add noise without helping the UI/LLM. |
| 282 | const hasNonEmptyObject = variants.some((v) => isObjectSchema(v) && Object.keys(asRecord(v.properties)).length > 0); | 286 | const hasNonEmptyObject = variants.some((v) => isObjectSchema(v) && Object.keys(toRecordOrEmpty(v.properties)).length > 0); |
| 283 | 287 | ||
| 284 | let filtered = variants; | 288 | let filtered = variants; |
| 285 | if (hasNonEmptyObject) { | 289 | if (hasNonEmptyObject) { |
| @@ -324,7 +328,7 @@ function repairMissingRequiredProperties( · 38 unmodified lines | |||
| 324 | const requiredLists = variants.map((v) => new Set( | 328 | const requiredLists = variants.map((v) => new Set( |
| 325 | (Array.isArray(v.required) ? v.required : []).filter((k): k is string => typeof k === "string"), | 329 | (Array.isArray(v.required) ? v.required : []).filter((k): k is string => typeof k === "string"), |
| 326 | )); | 330 | )); |
| 327 | const propsLists = variants.map((v) => asRecord(v.properties)); | 331 | const propsLists = variants.map((v) => toRecordOrEmpty(v.properties)); |
| 328 | 332 | ||
| 329 | const keysToConsider = new Set<string>(); | 333 | const keysToConsider = new Set<string>(); |
| 330 | for (const req of requiredLists) { | 334 | for (const req of requiredLists) { |
| @@ -342,7 +346,7 @@ function repairMissingRequiredProperties( · 11 unmodified lines | |||
| 342 | if (!schema || typeof schema !== "object") continue; | 346 | if (!schema || typeof schema !== "object") continue; |
| 343 | const hint = jsonSchemaTypeHintFallback(schema, depth + 1, componentSchemas, seenRefs); | 347 | const hint = jsonSchemaTypeHintFallback(schema, depth + 1, componentSchemas, seenRefs); |
| 344 | if (!hint || hint === "unknown") continue; | 348 | if (!hint || hint === "unknown") continue; |
| 345 | candidates.push({ hint, schema: asRecord(schema) }); | 349 | candidates.push({ hint, schema: toRecordOrEmpty(schema) }); |
| 346 | } | 350 | } |
| 347 | 351 | ||
| 348 | if (candidates.length === 0) continue; | 352 | if (candidates.length === 0) continue; |
| @@ -416,8 +420,8 @@ function mergeDiscriminatedObjectUnionVariants( · 67 unmodified lines | |||
| 416 | 420 | ||
| 417 | for (let i = 0; i < variants.length; i++) { | 421 | for (let i = 0; i < variants.length; i++) { |
| 418 | const variant = variants[i]!; | 422 | const variant = variants[i]!; |
| 419 | const props = asRecord(variant.properties); | 423 | const props = toRecordOrEmpty(variant.properties); |
| 420 | const discSchema = asRecord(props[discriminantKey]); | 424 | const discSchema = toRecordOrEmpty(props[discriminantKey]); |
| 421 | const disc = extractStringLiteralFromSchema(discSchema); | 425 | const disc = extractStringLiteralFromSchema(discSchema); |
| 422 | if (!disc) { | 426 | if (!disc) { |
| 423 | return variants; | 427 | return variants; |
| @@ -479,8 +483,8 @@ function mergeDiscriminatedObjectUnionVariants( · 55 unmodified lines | |||
| 479 | for (const member of group) used.add(member.idx); | 483 | for (const member of group) used.add(member.idx); |
| 480 | 484 | ||
| 481 | const base = group[0]!.schema; | 485 | const base = group[0]!.schema; |
| 482 | const baseProps = asRecord(base.properties); | 486 | const baseProps = toRecordOrEmpty(base.properties); |
| 483 | const discSchema = asRecord(baseProps[discriminantKey]); | 487 | const discSchema = toRecordOrEmpty(baseProps[discriminantKey]); |
| 484 | const discType = typeof discSchema.type === "string" ? discSchema.type : "string"; | 488 | const discType = typeof discSchema.type === "string" ? discSchema.type : "string"; |
| 485 | 489 | ||
| 486 | const mergedDiscriminant: JsonSchema = { | 490 | const mergedDiscriminant: JsonSchema = { |
| @@ -521,8 +525,8 @@ function tryCollapseSimpleObjectUnion( · 34 unmodified lines | |||
| 521 | const right = variants[1]!; | 525 | const right = variants[1]!; |
| 522 | if (!isObjectSchema(left) || !isObjectSchema(right)) return null; | 526 | if (!isObjectSchema(left) || !isObjectSchema(right)) return null; |
| 523 | 527 | ||
| 524 | const leftProps = asRecord(left.properties); | 528 | const leftProps = toRecordOrEmpty(left.properties); |
| 525 | const rightProps = asRecord(right.properties); | 529 | const rightProps = toRecordOrEmpty(right.properties); |
| 526 | const leftKeys = Object.keys(leftProps); | 530 | const leftKeys = Object.keys(leftProps); |
| 527 | const rightKeys = Object.keys(rightProps); | 531 | const rightKeys = Object.keys(rightProps); |
| 528 | if (leftKeys.length === 0 || rightKeys.length === 0) return null; | 532 | if (leftKeys.length === 0 || rightKeys.length === 0) return null; |
| @@ -535,8 +539,8 @@ function tryCollapseSimpleObjectUnion( · 6 unmodified lines | |||
| 535 | 539 | ||
| 536 | const sup = leftSubRight ? right : left; | 540 | const sup = leftSubRight ? right : left; |
| 537 | const sub = leftSubRight ? left : right; | 541 | const sub = leftSubRight ? left : right; |
| 538 | const supProps = asRecord(sup.properties); | 542 | const supProps = toRecordOrEmpty(sup.properties); |
| 539 | const subProps = asRecord(sub.properties); | 543 | const subProps = toRecordOrEmpty(sub.properties); |
| 540 | const supKeys = Object.keys(supProps); | 544 | const supKeys = Object.keys(supProps); |
| 541 | const subKeys = Object.keys(subProps); | 545 | const subKeys = Object.keys(subProps); |
| 542 | const extraKeys = supKeys.filter((k) => !subKeys.includes(k)); | 546 | const extraKeys = supKeys.filter((k) => !subKeys.includes(k)); |
| @@ -591,13 +595,13 @@ function tryFactorCommonObjectFields( · 48 unmodified lines | |||
| 591 | if (variants.length < 2) return null; | 595 | if (variants.length < 2) return null; |
| 592 | if (!variants.every(isObjectSchema)) return null; | 596 | if (!variants.every(isObjectSchema)) return null; |
| 593 | 597 | ||
| 594 | const propsList = variants.map((v) => asRecord(v.properties)); | 598 | const propsList = variants.map((v) => toRecordOrEmpty(v.properties)); |
| 595 | const commonKeys = intersectKeys(propsList); | 599 | const commonKeys = intersectKeys(propsList); |
| 596 | if (commonKeys.length === 0) return null; | 600 | if (commonKeys.length === 0) return null; |
| 597 | 601 | ||
| 598 | const commonEntries: Array<{ key: string; hint: string; required: boolean }> = []; | 602 | const commonEntries: Array<{ key: string; hint: string; required: boolean }> = []; |
| 599 | for (const key of commonKeys) { | 603 | for (const key of commonKeys) { |
| 600 | const hints = variants.map((v) => jsonSchemaTypeHintFallback(asRecord(v.properties)[key], depth + 1, componentSchemas, seenRefs)); | 604 | const hints = variants.map((v) => jsonSchemaTypeHintFallback(toRecordOrEmpty(v.properties)[key], depth + 1, componentSchemas, seenRefs)); |
| 601 | const firstHint = hints[0]; | 605 | const firstHint = hints[0]; |
| 602 | if (!firstHint || hints.some((h) => h !== firstHint)) continue; | 606 | if (!firstHint || hints.some((h) => h !== firstHint)) continue; |
| 603 | 607 | ||
| @@ -613,7 +617,7 @@ function tryFactorCommonObjectFields( · 9 unmodified lines | |||
| 613 | const commonProps = new Set(commonEntries.map((e) => e.key)); | 617 | const commonProps = new Set(commonEntries.map((e) => e.key)); |
| 614 | const residualSchemas: JsonSchema[] = []; | 618 | const residualSchemas: JsonSchema[] = []; |
| 615 | for (const variant of variants) { | 619 | for (const variant of variants) { |
| 616 | const props = asRecord(variant.properties); | 620 | const props = toRecordOrEmpty(variant.properties); |
| 617 | const residualProps = Object.fromEntries(Object.entries(props).filter(([key]) => !commonProps.has(key))); | 621 | const residualProps = Object.fromEntries(Object.entries(props).filter(([key]) => !commonProps.has(key))); |
| 618 | const req = Array.isArray(variant.required) ? variant.required : []; | 622 | const req = Array.isArray(variant.required) ? variant.required : []; |
| 619 | const residualRequired = req.filter((key) => typeof key === "string" && !commonProps.has(key)); | 623 | const residualRequired = req.filter((key) => typeof key === "string" && !commonProps.has(key)); |
| @@ -625,7 +629,7 @@ function tryFactorCommonObjectFields( · 5 unmodified lines | |||
| 625 | } | 629 | } |
| 626 | 630 | ||
| 627 | // If factoring doesn't reduce anything, bail. | 631 | // If factoring doesn't reduce anything, bail. |
| 628 | const reduces = residualSchemas.some((s) => Object.keys(asRecord(s.properties)).length > 0); | 632 | const reduces = residualSchemas.some((s) => Object.keys(toRecordOrEmpty(s.properties)).length > 0); |
| 629 | if (!reduces) return null; | 633 | if (!reduces) return null; |
| 630 | 634 | ||
| 631 | const commonInner = commonEntries | 635 | const commonInner = commonEntries |
| @@ -662,7 +666,7 @@ function tryFactorPartialCommonObjectFields( · 30 unmodified lines | |||
| 662 | if (variants.length < 3) return null; | 666 | if (variants.length < 3) return null; |
| 663 | if (!variants.every(isObjectSchema)) return null; | 667 | if (!variants.every(isObjectSchema)) return null; |
| 664 | 668 | ||
| 665 | const propsList = variants.map((v) => asRecord(v.properties)); | 669 | const propsList = variants.map((v) => toRecordOrEmpty(v.properties)); |
| 666 | const requiredList = variants.map((v) => new Set( | 670 | const requiredList = variants.map((v) => new Set( |
| 667 | (Array.isArray(v.required) ? v.required : []).filter((k): k is string => typeof k === "string"), | 671 | (Array.isArray(v.required) ? v.required : []).filter((k): k is string => typeof k === "string"), |
| 668 | )); | 672 | )); |
| @@ -753,7 +757,7 @@ function tryFactorPartialCommonObjectFields( · 84 unmodified lines | |||
| 753 | 757 | ||
| 754 | // If factoring doesn't actually remove anything useful, bail. | 758 | // If factoring doesn't actually remove anything useful, bail. |
| 755 | const anyResidualHasProps = residualSchemas.some( | 759 | const anyResidualHasProps = residualSchemas.some( |
| 756 | (s) => Object.keys(asRecord(s.properties)).length > 0, | 760 | (s) => Object.keys(toRecordOrEmpty(s.properties)).length > 0, |
| 757 | ); | 761 | ); |
| 758 | if (!anyResidualHasProps) return null; | 762 | if (!anyResidualHasProps) return null; |
| 759 | 763 | ||
| @@ -788,13 +792,13 @@ export function jsonSchemaTypeHintFallback( · 28 unmodified lines | |||
| 788 | if (!schema || typeof schema !== "object") return "unknown"; | 792 | if (!schema || typeof schema !== "object") return "unknown"; |
| 789 | if (depth > 12) return "unknown"; | 793 | if (depth > 12) return "unknown"; |
| 790 | 794 | ||
| 791 | const shape = asRecord(schema); | 795 | const shape = toRecordOrEmpty(schema); |
| 792 | if (typeof shape.$ref === "string") { | 796 | if (typeof shape.$ref === "string") { |
| 793 | const ref = shape.$ref; | 797 | const ref = shape.$ref; |
| 794 | const prefix = "#/components/schemas/"; | 798 | const prefix = "#/components/schemas/"; |
| 795 | if (ref.startsWith(prefix)) { | 799 | if (ref.startsWith(prefix)) { |
| 796 | const key = ref.slice(prefix.length); | 800 | const key = ref.slice(prefix.length); |
| 797 | const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {}; | 801 | const resolved = componentSchemas ? toRecordOrEmpty(componentSchemas[key]) : {}; |
| 798 | const canInline = Object.keys(resolved).length > 0 | 802 | const canInline = Object.keys(resolved).length > 0 |
| 799 | && !seenRefs.has(ref) | 803 | && !seenRefs.has(ref) |
| 800 | && (depth < COMPONENT_REF_INLINE_DEPTH || isSmallInlineableComponentSchema(resolved)); | 804 | && (depth < COMPONENT_REF_INLINE_DEPTH || isSmallInlineableComponentSchema(resolved)); |
| @@ -934,7 +938,7 @@ export function jsonSchemaTypeHintFallback( · 133 unmodified lines | |||
| 934 | return `${maybeParenthesizeArrayElement(itemType)}[]`; | 938 | return `${maybeParenthesizeArrayElement(itemType)}[]`; |
| 935 | } | 939 | } |
| 936 | 940 | ||
| 937 | const props = asRecord(shape.properties); | 941 | const props = toRecordOrEmpty(shape.properties); |
| 938 | const additionalProperties = shape.additionalProperties; | 942 | const additionalProperties = shape.additionalProperties; |
| 939 | const requiredRaw = Array.isArray(shape.required) ? shape.required : []; | 943 | const requiredRaw = Array.isArray(shape.required) ? shape.required : []; |
| 940 | const required = new Set(requiredRaw.filter((item): item is string => typeof item === "string")); | 944 | const required = new Set(requiredRaw.filter((item): item is string => typeof item === "string")); |
| @@ -973,12 +977,12 @@ function collectTopLevelSchemaKeys( · 32 unmodified lines | |||
| 973 | ): string[] { | 977 | ): string[] { |
| 974 | if (!schema || typeof schema !== "object") return []; | 978 | if (!schema || typeof schema !== "object") return []; |
| 975 | 979 | ||
| 976 | const record = asRecord(schema); | 980 | const record = toRecordOrEmpty(schema); |
| 977 | const ref = typeof record.$ref === "string" ? record.$ref : ""; | 981 | const ref = typeof record.$ref === "string" ? record.$ref : ""; |
| 978 | if (ref.startsWith("#/components/schemas/")) { | 982 | if (ref.startsWith("#/components/schemas/")) { |
| 979 | if (seenRefs.has(ref)) return []; | 983 | if (seenRefs.has(ref)) return []; |
| 980 | const key = ref.slice("#/components/schemas/".length); | 984 | const key = ref.slice("#/components/schemas/".length); |
| 981 | const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {}; | 985 | const resolved = componentSchemas ? toRecordOrEmpty(componentSchemas[key]) : {}; |
| 982 | if (Object.keys(resolved).length === 0) return []; | 986 | if (Object.keys(resolved).length === 0) return []; |
| 983 | const nextSeen = new Set(seenRefs); | 987 | const nextSeen = new Set(seenRefs); |
| 984 | nextSeen.add(ref); | 988 | nextSeen.add(ref); |
| @@ -988,7 +992,7 @@ function collectTopLevelSchemaKeys( · 3 unmodified lines | |||
| 988 | const keys: string[] = []; | 992 | const keys: string[] = []; |
| 989 | const seen = new Set<string>(); | 993 | const seen = new Set<string>(); |
| 990 | 994 | ||
| 991 | for (const key of Object.keys(asRecord(record.properties))) { | 995 | for (const key of Object.keys(toRecordOrEmpty(record.properties))) { |
| 992 | pushUnique(keys, seen, key); | 996 | pushUnique(keys, seen, key); |
| 993 | } | 997 | } |
| 994 | 998 | ||
| @@ -1,3 +1,4 @@ | |||
| 1 | import { z } from "zod"; | ||
| 1 | import { | 2 | import { |
| 2 | buildOpenApiArgPreviewKeys, | 3 | buildOpenApiArgPreviewKeys, |
| 3 | buildOpenApiInputSchema, | 4 | buildOpenApiInputSchema, |
| @@ -6,7 +7,6 @@ import { · 2 unmodified lines | |||
| 6 | jsonSchemaTypeHintFallback, | 7 | jsonSchemaTypeHintFallback, |
| 7 | responseTypeHintFromSchema, | 8 | responseTypeHintFromSchema, |
| 8 | } from "./schema-hints"; | 9 | } from "./schema-hints"; |
| 9 | import { z } from "zod"; | ||
| 10 | import { buildOpenApiToolPath } from "./tool-path"; | 10 | import { buildOpenApiToolPath } from "./tool-path"; |
| 11 | import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth"; | 11 | import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth"; |
| 12 | import { executeOpenApiRequest } from "../tool/source-execution"; | 12 | import { executeOpenApiRequest } from "../tool/source-execution"; |
| @@ -14,7 +14,7 @@ import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "../tool/sourc · 1 unmodified line | |||
| 14 | import type { ToolDefinition } from "../types"; | 14 | import type { ToolDefinition } from "../types"; |
| 15 | import type { ToolTypedRef } from "../types"; | 15 | import type { ToolTypedRef } from "../types"; |
| 16 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils"; | 16 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils"; |
| 17 | import { asRecord } from "../utils"; | 17 | import { toPlainObject } from "../utils"; |
| 18 | import type { SerializedTool } from "../tool/source-serialization"; | 18 | import type { SerializedTool } from "../tool/source-serialization"; |
| 19 | 19 | ||
| 20 | type OpenApiOperationParameter = { | 20 | type OpenApiOperationParameter = { |
| @@ -24,11 +24,29 @@ type OpenApiOperationParameter = { · 3 unmodified lines | |||
| 24 | schema: Record<string, unknown>; | 24 | schema: Record<string, unknown>; |
| 25 | }; | 25 | }; |
| 26 | 26 | ||
| 27 | const recordArraySchema = z.array(z.record(z.unknown())); | 27 | const stringArraySchema = z.array(z.string()); |
| 28 | const nonEmptyTrimmedStringSchema = z.string().transform((value) => value.trim()).refine((value) => value.length > 0); | ||
| 28 | 29 | ||
| 29 | function asRecordArray(value: unknown): Record<string, unknown>[] { | 30 | function toRecordOrEmpty(value: unknown): Record<string, unknown> { |
| 30 | const parsed = recordArraySchema.safeParse(value); | 31 | return toPlainObject(value) ?? {}; |
| 31 | return parsed.success ? parsed.data : []; | 32 | } |
| 33 | |||
| 34 | function parseNonEmptyTrimmedString(value: unknown): string | undefined { | ||
| 35 | const parsed = nonEmptyTrimmedStringSchema.safeParse(value); | ||
| 36 | return parsed.success ? parsed.data : undefined; | ||
| 37 | } | ||
| 38 | |||
| 39 | function toRecordArray(value: unknown): Array<Record<string, unknown>> { | ||
| 40 | if (!Array.isArray(value)) return []; | ||
| 41 | |||
| 42 | const entries: Array<Record<string, unknown>> = []; | ||
| 43 | for (const item of value) { | ||
| 44 | const record = toPlainObject(item); | ||
| 45 | if (record) { | ||
| 46 | entries.push(record); | ||
| 47 | } | ||
| 48 | } | ||
| 49 | return entries; | ||
| 32 | } | 50 | } |
| 33 | 51 | ||
| 34 | function buildOpenApiOperationParameters( | 52 | function buildOpenApiOperationParameters( |
| @@ -37,12 +55,12 @@ function buildOpenApiOperationParameters( · 2 unmodified lines | |||
| 37 | ): OpenApiOperationParameter[] { | 55 | ): OpenApiOperationParameter[] { |
| 38 | return [ | 56 | return [ |
| 39 | ...sharedParameters, | 57 | ...sharedParameters, |
| 40 | ...asRecordArray(operation.parameters), | 58 | ...toRecordArray(operation.parameters), |
| 41 | ].map((entry) => ({ | 59 | ].map((entry) => ({ |
| 42 | name: String(entry.name ?? ""), | 60 | name: String(entry.name ?? ""), |
| 43 | in: String(entry.in ?? "query"), | 61 | in: String(entry.in ?? "query"), |
| 44 | required: Boolean(entry.required), | 62 | required: Boolean(entry.required), |
| 45 | schema: asRecord(entry.schema), | 63 | schema: toRecordOrEmpty(entry.schema), |
| 46 | })); | 64 | })); |
| 47 | } | 65 | } |
| 48 | 66 | ||
| @@ -60,7 +78,7 @@ export function buildOpenApiToolsFromPrepared( · 11 unmodified lines | |||
| 60 | const sourceLabel = `openapi:${config.name}`; | 78 | const sourceLabel = `openapi:${config.name}`; |
| 61 | const credentialSourceKey = getCredentialSourceKey(config); | 79 | const credentialSourceKey = getCredentialSourceKey(config); |
| 62 | const credentialSpec = buildCredentialSpec(credentialSourceKey, effectiveAuth); | 80 | const credentialSpec = buildCredentialSpec(credentialSourceKey, effectiveAuth); |
| 63 | const paths = asRecord(prepared.paths); | 81 | const paths = toRecordOrEmpty(prepared.paths); |
| 64 | const tools: ToolDefinition[] = []; | 82 | const tools: ToolDefinition[] = []; |
| 65 | 83 | ||
| 66 | const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const; | 84 | const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const; |
| @@ -68,11 +86,11 @@ export function buildOpenApiToolsFromPrepared( · 1 unmodified line | |||
| 68 | const usedToolPaths = new Set<string>(); | 86 | const usedToolPaths = new Set<string>(); |
| 69 | 87 | ||
| 70 | for (const [pathTemplate, pathValue] of Object.entries(paths)) { | 88 | for (const [pathTemplate, pathValue] of Object.entries(paths)) { |
| 71 | const pathObject = asRecord(pathValue); | 89 | const pathObject = toRecordOrEmpty(pathValue); |
| 72 | const sharedParameters = asRecordArray(pathObject.parameters); | 90 | const sharedParameters = toRecordArray(pathObject.parameters); |
| 73 | 91 | ||
| 74 | for (const method of methods) { | 92 | for (const method of methods) { |
| 75 | const operation = asRecord(pathObject[method]); | 93 | const operation = toRecordOrEmpty(pathObject[method]); |
| 76 | if (Object.keys(operation).length === 0) continue; | 94 | if (Object.keys(operation).length === 0) continue; |
| 77 | 95 | ||
| 78 | const tags = Array.isArray(operation.tags) ? operation.tags : []; | 96 | const tags = Array.isArray(operation.tags) ? operation.tags : []; |
| @@ -80,22 +98,19 @@ export function buildOpenApiToolsFromPrepared( · 1 unmodified line | |||
| 80 | const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`); | 98 | const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`); |
| 81 | const parameters = buildOpenApiOperationParameters(sharedParameters, operation); | 99 | const parameters = buildOpenApiOperationParameters(sharedParameters, operation); |
| 82 | 100 | ||
| 83 | const inputSchema = asRecord(operation._inputSchema); | 101 | const inputSchema = toRecordOrEmpty(operation._inputSchema); |
| 84 | const outputSchema = asRecord(operation._outputSchema); | 102 | const outputSchema = toRecordOrEmpty(operation._outputSchema); |
| 85 | const inputHint = typeof operation._argsTypeHint === "string" && operation._argsTypeHint.trim().length > 0 | 103 | const inputHint = parseNonEmptyTrimmedString(operation._argsTypeHint); |
| 86 | ? operation._argsTypeHint.trim() | 104 | const outputHint = parseNonEmptyTrimmedString(operation._returnsTypeHint); |
| 87 | : undefined; | ||
| 88 | const outputHint = typeof operation._returnsTypeHint === "string" && operation._returnsTypeHint.trim().length > 0 | ||
| 89 | ? operation._returnsTypeHint.trim() | ||
| 90 | : undefined; | ||
| 91 | const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema); | 105 | const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema); |
| 92 | const previewInputKeys = Array.isArray(operation._previewInputKeys) | 106 | const parsedPreviewInputKeys = stringArraySchema.safeParse(operation._previewInputKeys); |
| 93 | ? operation._previewInputKeys.filter((value): value is string => typeof value === "string") | 107 | const previewInputKeys = parsedPreviewInputKeys.success |
| 108 | ? parsedPreviewInputKeys.data | ||
| 94 | : buildPreviewKeys(inputSchema); | 109 | : buildPreviewKeys(inputSchema); |
| 95 | 110 | ||
| 96 | const typedRef: ToolTypedRef = { | 111 | const typedRef: ToolTypedRef = { |
| 97 | kind: "openapi_operation", | 112 | kind: "openapi_operation", |
| 98 | sourceKey: sourceLabel, | 113 | sourceKey: config.sourceKey ?? sourceLabel, |
| 99 | operationId: operationIdRaw, | 114 | operationId: operationIdRaw, |
| 100 | }; | 115 | }; |
| 101 | 116 | ||
| @@ -1,6 +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 { z } from "zod"; |
| 3 | import { asRecord } from "./utils"; | ||
| 4 | import type { PostmanRequestBody } from "./postman/collection-utils"; | 3 | import type { PostmanRequestBody } from "./postman/collection-utils"; |
| 5 | 4 | ||
| 6 | export interface PostmanSerializedRunSpec { | 5 | export interface PostmanSerializedRunSpec { |
| @@ -21,6 +20,13 @@ const postmanRuntimePayloadSchema = z.object({ · 14 unmodified lines | |||
| 21 | body: z.unknown().optional(), | 20 | body: z.unknown().optional(), |
| 22 | }).passthrough(); | 21 | }).passthrough(); |
| 23 | 22 | ||
| 23 | const recordSchema = z.record(z.unknown()); | ||
| 24 | |||
| 25 | function coerceRecord(value: unknown): Record<string, unknown> { | ||
| 26 | const parsed = recordSchema.safeParse(value); | ||
| 27 | return parsed.success ? parsed.data : {}; | ||
| 28 | } | ||
| 29 | |||
| 24 | export async function executePostmanRequest( | 30 | export async function executePostmanRequest( |
| 25 | runSpec: PostmanSerializedRunSpec, | 31 | runSpec: PostmanSerializedRunSpec, |
| 26 | payload: Record<string, unknown>, | 32 | payload: Record<string, unknown>, |
| @@ -55,7 +61,7 @@ export async function executePostmanRequest( · 28 unmodified lines | |||
| 55 | } | 61 | } |
| 56 | } | 62 | } |
| 57 | 63 | ||
| 58 | const queryOverrides = asRecord(normalizedPayload.query); | 64 | const queryOverrides = coerceRecord(normalizedPayload.query); |
| 59 | for (const [key, value] of Object.entries(queryOverrides)) { | 65 | for (const [key, value] of Object.entries(queryOverrides)) { |
| 60 | if (!key || value === undefined || value === null) continue; | 66 | if (!key || value === undefined || value === null) continue; |
| 61 | url.searchParams.set(key, stringifyTemplateValue(value)); | 67 | url.searchParams.set(key, stringifyTemplateValue(value)); |
| @@ -69,7 +75,7 @@ export async function executePostmanRequest( · 7 unmodified lines | |||
| 69 | Object.assign(headers, runSpec.authHeaders); | 75 | Object.assign(headers, runSpec.authHeaders); |
| 70 | Object.assign(headers, credentialHeaders ?? {}); | 76 | Object.assign(headers, credentialHeaders ?? {}); |
| 71 | 77 | ||
| 72 | const headerOverrides = asRecord(normalizedPayload.headers); | 78 | const headerOverrides = coerceRecord(normalizedPayload.headers); |
| 73 | for (const [name, value] of Object.entries(headerOverrides)) { | 79 | for (const [name, value] of Object.entries(headerOverrides)) { |
| 74 | if (!name || value === undefined || value === null) continue; | 80 | if (!name || value === undefined || value === null) continue; |
| 75 | headers[name] = stringifyTemplateValue(value); | 81 | headers[name] = stringifyTemplateValue(value); |
| @@ -1,6 +1,12 @@ | |||
| 1 | import { asRecord } from "./utils"; | 1 | import { z } from "zod"; |
| 2 | 2 | ||
| 3 | const POSTMAN_TEMPLATE_PATTERN = /\{\{([^{}]+)\}\}/g; | 3 | const POSTMAN_TEMPLATE_PATTERN = /\{\{([^{}]+)\}\}/g; |
| 4 | const recordSchema = z.record(z.unknown()); | ||
| 5 | |||
| 6 | function coerceRecord(value: unknown): Record<string, unknown> { | ||
| 7 | const parsed = recordSchema.safeParse(value); | ||
| 8 | return parsed.success ? parsed.data : {}; | ||
| 9 | } | ||
| 4 | 10 | ||
| 5 | export function stringifyTemplateValue(value: unknown): string { | 11 | export function stringifyTemplateValue(value: unknown): string { |
| 6 | if (value === null || value === undefined) return ""; | 12 | if (value === null || value === undefined) return ""; |
| @@ -29,7 +35,7 @@ export function findUnresolvedPostmanTemplateKeys(value: string): string[] { · 22 unmodified lines | |||
| 29 | 35 | ||
| 30 | export function asStringRecord(value: unknown): Record<string, string> { | 36 | export function asStringRecord(value: unknown): Record<string, string> { |
| 31 | const result: Record<string, string> = {}; | 37 | const result: Record<string, string> = {}; |
| 32 | for (const [key, entry] of Object.entries(asRecord(value))) { | 38 | for (const [key, entry] of Object.entries(coerceRecord(value))) { |
| 33 | result[key] = stringifyTemplateValue(entry); | 39 | result[key] = stringifyTemplateValue(entry); |
| 34 | } | 40 | } |
| 35 | return result; | 41 | return result; |
| @@ -1,11 +1,50 @@ | |||
| 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 | import { z } from "zod"; |
| 4 | 4 | ||
| 5 | export type PostmanRequestBody = | 5 | export type PostmanRequestBody = |
| 6 | | { kind: "urlencoded"; entries: Array<{ key: string; value: string }> } | 6 | | { kind: "urlencoded"; entries: Array<{ key: string; value: string }> } |
| 7 | | { kind: "raw"; text: string }; | 7 | | { kind: "raw"; text: string }; |
| 8 | 8 | ||
| 9 | const postmanKeyValueEntrySchema = z.object({ | ||
| 10 | key: z.coerce.string(), | ||
| 11 | value: z.unknown().optional(), | ||
| 12 | disabled: z.boolean().optional(), | ||
| 13 | enabled: z.boolean().optional(), | ||
| 14 | }); | ||
| 15 | |||
| 16 | const postmanBodySchema = z.object({ | ||
| 17 | mode: z.string().optional(), | ||
| 18 | urlencoded: z.array(postmanKeyValueEntrySchema).optional(), | ||
| 19 | raw: z.string().optional(), | ||
| 20 | dataMode: z.string().optional(), | ||
| 21 | data: z.array(postmanKeyValueEntrySchema).optional(), | ||
| 22 | rawModeData: z.string().optional(), | ||
| 23 | }); | ||
| 24 | |||
| 25 | function parseKeyValueEntries(value: unknown): Array<z.infer<typeof postmanKeyValueEntrySchema>> { | ||
| 26 | const parsed = z.array(postmanKeyValueEntrySchema).safeParse(value); | ||
| 27 | return parsed.success ? parsed.data : []; | ||
| 28 | } | ||
| 29 | |||
| 30 | function isDisabled(entry: z.infer<typeof postmanKeyValueEntrySchema>): boolean { | ||
| 31 | return entry.disabled === true || entry.enabled === false; | ||
| 32 | } | ||
| 33 | |||
| 34 | function toNormalizedEntry( | ||
| 35 | entry: z.infer<typeof postmanKeyValueEntrySchema>, | ||
| 36 | ): { key: string; value: string } | null { | ||
| 37 | const key = entry.key.trim(); | ||
| 38 | if (!key || isDisabled(entry)) { | ||
| 39 | return null; | ||
| 40 | } | ||
| 41 | |||
| 42 | return { | ||
| 43 | key, | ||
| 44 | value: stringifyTemplateValue(entry.value), | ||
| 45 | }; | ||
| 46 | } | ||
| 47 | |||
| 9 | export function buildPostmanToolPath( | 48 | export function buildPostmanToolPath( |
| 10 | sourceName: string, | 49 | sourceName: string, |
| 11 | requestName: string, | 50 | requestName: string, |
| @@ -48,57 +87,63 @@ export function resolvePostmanFolderPath( · 36 unmodified lines | |||
| 48 | } | 87 | } |
| 49 | 88 | ||
| 50 | export function extractPostmanVariableMap(value: unknown): Record<string, string> { | 89 | export function extractPostmanVariableMap(value: unknown): Record<string, string> { |
| 51 | if (!Array.isArray(value)) return {}; | ||
| 52 | const result: Record<string, string> = {}; | 90 | const result: Record<string, string> = {}; |
| 53 | for (const entry of value) { | 91 | for (const entry of parseKeyValueEntries(value)) { |
| 54 | const record = asRecord(entry); | 92 | const normalized = toNormalizedEntry(entry); |
| 55 | const key = typeof record.key === "string" ? record.key.trim() : ""; | 93 | if (!normalized) continue; |
| 56 | if (!key) continue; | 94 | result[normalized.key] = normalized.value; |
| 57 | if (record.disabled === true) continue; | ||
| 58 | result[key] = stringifyTemplateValue(record.value); | ||
| 59 | } | 95 | } |
| 60 | return result; | 96 | return result; |
| 61 | } | 97 | } |
| 62 | 98 | ||
| 63 | export function extractPostmanHeaderMap(value: unknown): Record<string, string> { | 99 | export function extractPostmanHeaderMap(value: unknown): Record<string, string> { |
| 64 | if (!Array.isArray(value)) return {}; | ||
| 65 | const result: Record<string, string> = {}; | 100 | const result: Record<string, string> = {}; |
| 66 | for (const entry of value) { | 101 | for (const entry of parseKeyValueEntries(value)) { |
| 67 | const record = asRecord(entry); | 102 | const normalized = toNormalizedEntry(entry); |
| 68 | const key = typeof record.key === "string" ? record.key.trim() : ""; | 103 | if (!normalized) continue; |
| 69 | if (!key || record.disabled === true) continue; | 104 | result[normalized.key] = normalized.value; |
| 70 | result[key] = stringifyTemplateValue(record.value); | ||
| 71 | } | 105 | } |
| 72 | return result; | 106 | return result; |
| 73 | } | 107 | } |
| 74 | 108 | ||
| 75 | export function extractPostmanQueryEntries(value: unknown): Array<{ key: string; value: string }> { | 109 | export function extractPostmanQueryEntries(value: unknown): Array<{ key: string; value: string }> { |
| 76 | if (!Array.isArray(value)) return []; | ||
| 77 | const entries: Array<{ key: string; value: string }> = []; | 110 | const entries: Array<{ key: string; value: string }> = []; |
| 78 | for (const entry of value) { | 111 | for (const entry of parseKeyValueEntries(value)) { |
| 79 | const record = asRecord(entry); | 112 | const normalized = toNormalizedEntry(entry); |
| 80 | const key = typeof record.key === "string" ? record.key.trim() : ""; | 113 | if (!normalized) continue; |
| 81 | if (!key || record.disabled === true) continue; | 114 | entries.push(normalized); |
| 82 | entries.push({ key, value: stringifyTemplateValue(record.value) }); | ||
| 83 | } | 115 | } |
| 84 | return entries; | 116 | return entries; |
| 85 | } | 117 | } |
| 86 | 118 | ||
| 87 | export function extractPostmanBody(record: Record<string, unknown>): PostmanRequestBody | undefined { | 119 | export function extractPostmanBody(value: unknown): PostmanRequestBody | undefined { |
| 88 | const dataMode = typeof record.dataMode === "string" ? record.dataMode.toLowerCase() : ""; | 120 | const parsedBody = postmanBodySchema.safeParse(value); |
| 89 | if (dataMode === "urlencoded" && Array.isArray(record.data)) { | 121 | if (!parsedBody.success) return undefined; |
| 90 | const entries: Array<{ key: string; value: string }> = []; | 122 | |
| 91 | for (const item of record.data) { | 123 | const body = parsedBody.data; |
| 92 | const entry = asRecord(item); | 124 | const mode = (body.mode ?? body.dataMode ?? "").trim().toLowerCase(); |
| 93 | const key = typeof entry.key === "string" ? entry.key.trim() : ""; | 125 | |
| 94 | if (!key || entry.disabled === true) continue; | 126 | if (mode === "urlencoded") { |
| 95 | entries.push({ key, value: stringifyTemplateValue(entry.value) }); | 127 | const rawEntries = body.urlencoded ?? body.data ?? []; |
| 96 | } | 128 | const entries = rawEntries |
| 129 | .map((entry) => toNormalizedEntry(entry)) | ||
| 130 | .filter((entry): entry is { key: string; value: string } => Boolean(entry)); | ||
| 97 | return entries.length > 0 ? { kind: "urlencoded", entries } : undefined; | 131 | return entries.length > 0 ? { kind: "urlencoded", entries } : undefined; |
| 98 | } | 132 | } |
| 99 | 133 | ||
| 100 | if (typeof record.rawModeData === "string" && record.rawModeData.length > 0) { | 134 | if (mode === "raw") { |
| 101 | return { kind: "raw", text: record.rawModeData }; | 135 | const rawText = body.raw ?? body.rawModeData ?? ""; |
| 136 | if (rawText.trim().length > 0) { | ||
| 137 | return { kind: "raw", text: rawText }; | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | if ((body.rawModeData ?? "").trim().length > 0) { | ||
| 142 | return { kind: "raw", text: body.rawModeData ?? "" }; | ||
| 143 | } | ||
| 144 | |||
| 145 | if ((body.raw ?? "").trim().length > 0) { | ||
| 146 | return { kind: "raw", text: body.raw ?? "" }; | ||
| 102 | } | 147 | } |
| 103 | 148 | ||
| 104 | return undefined; | 149 | return undefined; |
| @@ -39,16 +39,22 @@ function sanitizeExecutionResult(value: unknown): unknown { · 38 unmodified lines | |||
| 39 | return undefined; | 39 | return undefined; |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | const serialized = Result.try(() => JSON.stringify(value)); | 42 | let serialized: string | undefined; |
| 43 | if (serialized.isErr()) { | 43 | try { |
| 44 | serialized = JSON.stringify(value); | ||
| 45 | } catch { | ||
| 44 | return String(value); | 46 | return String(value); |
| 45 | } | 47 | } |
| 46 | 48 | ||
| 47 | if (serialized.value === undefined) { | 49 | if (serialized === undefined) { |
| 48 | return null; | 50 | return null; |
| 49 | } | 51 | } |
| 50 | 52 | ||
| 51 | return Result.try(() => JSON.parse(serialized.value)).unwrapOr(String(value)); | 53 | try { |
| 54 | return JSON.parse(serialized); | ||
| 55 | } catch { | ||
| 56 | return String(value); | ||
| 57 | } | ||
| 52 | } | 58 | } |
| 53 | 59 | ||
| 54 | function createToolsProxy( | 60 | function createToolsProxy( |
| @@ -10,7 +10,7 @@ import { · 9 unmodified lines | |||
| 10 | } from "./tool-discovery/formatting"; | 10 | } from "./tool-discovery/formatting"; |
| 11 | import { buildIndex, getTopLevelNamespace, listIndexForContext } from "./tool-discovery/indexing"; | 11 | import { buildIndex, getTopLevelNamespace, listIndexForContext } from "./tool-discovery/indexing"; |
| 12 | import { chooseBestPath, deriveIntentPhrase, extractNamespaceHints, scoreEntry } from "./tool-discovery/ranking"; | 12 | import { chooseBestPath, deriveIntentPhrase, extractNamespaceHints, scoreEntry } from "./tool-discovery/ranking"; |
| 13 | import { asRecord } from "./utils"; | 13 | const recordSchema = z.record(z.unknown()); |
| 14 | 14 | ||
| 15 | const catalogToolsInputSchema = z.object({ | 15 | const catalogToolsInputSchema = z.object({ |
| 16 | namespace: z.string().optional(), | 16 | namespace: z.string().optional(), |
| @@ -87,8 +87,9 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 70 unmodified lines | |||
| 87 | outputSchema: { type: "object", properties: { results: { type: "array" }, total: { type: "number" } }, required: ["results", "total"] }, | 87 | outputSchema: { type: "object", properties: { results: { type: "array" }, total: { type: "number" } }, required: ["results", "total"] }, |
| 88 | }, | 88 | }, |
| 89 | run: async (input: unknown, context) => { | 89 | run: async (input: unknown, context) => { |
| 90 | const payload = asRecord(input); | 90 | const payload = recordSchema.safeParse(input); |
| 91 | const parsedInput = catalogToolsInputSchema.safeParse(payload); | 91 | const normalizedPayload = payload.success ? payload.data : {}; |
| 92 | const parsedInput = catalogToolsInputSchema.safeParse(normalizedPayload); | ||
| 92 | const namespaceFilter = (parsedInput.success ? (parsedInput.data.namespace ?? "") : "").trim().toLowerCase(); | 93 | const namespaceFilter = (parsedInput.success ? (parsedInput.data.namespace ?? "") : "").trim().toLowerCase(); |
| 93 | const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase(); | 94 | const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase(); |
| 94 | const depthValue = parsedInput.success ? parsedInput.data.depth : undefined; | 95 | const depthValue = parsedInput.success ? parsedInput.data.depth : undefined; |
| @@ -177,8 +178,9 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 82 unmodified lines | |||
| 177 | }, | 178 | }, |
| 178 | }, | 179 | }, |
| 179 | run: async (input: unknown, context) => { | 180 | run: async (input: unknown, context) => { |
| 180 | const payload = asRecord(input); | 181 | const payload = recordSchema.safeParse(input); |
| 181 | const parsedInput = discoverInputSchema.safeParse(payload); | 182 | const normalizedPayload = payload.success ? payload.data : {}; |
| 183 | const parsedInput = discoverInputSchema.safeParse(normalizedPayload); | ||
| 182 | const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase(); | 184 | const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase(); |
| 183 | const depthValue = parsedInput.success ? parsedInput.data.depth : undefined; | 185 | const depthValue = parsedInput.success ? parsedInput.data.depth : undefined; |
| 184 | const depth = Math.max(0, Math.min(2, Number(depthValue ?? 1))); | 186 | const depth = Math.max(0, Math.min(2, Number(depthValue ?? 1))); |
| @@ -1,8 +1,8 @@ | |||
| 1 | import { z } from "zod"; | ||
| 1 | import type { ToolDefinition } from "../types"; | 2 | import type { ToolDefinition } from "../types"; |
| 2 | import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints"; | 3 | import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints"; |
| 3 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils"; | 4 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils"; |
| 4 | import { sanitizeJsonSchemaForConvex } from "../tool-typing/convex-sanitize"; | 5 | import { sanitizeJsonSchemaForConvex } from "../tool-typing/convex-sanitize"; |
| 5 | import { asRecord } from "../utils"; | ||
| 6 | import type { DiscoverIndexEntry } from "./types"; | 6 | import type { DiscoverIndexEntry } from "./types"; |
| 7 | 7 | ||
| 8 | const GENERIC_NAMESPACE_SUFFIXES = new Set([ | 8 | const GENERIC_NAMESPACE_SUFFIXES = new Set([ |
| @@ -14,13 +14,21 @@ const GENERIC_NAMESPACE_SUFFIXES = new Set([ · 5 unmodified lines | |||
| 14 | "services", | 14 | "services", |
| 15 | ]); | 15 | ]); |
| 16 | 16 | ||
| 17 | const recordSchema = z.record(z.unknown()); | ||
| 18 | const stringArraySchema = z.array(z.string()); | ||
| 19 | |||
| 20 | function coerceRecord(value: unknown): Record<string, unknown> { | ||
| 21 | const parsed = recordSchema.safeParse(value); | ||
| 22 | return parsed.success ? parsed.data : {}; | ||
| 23 | } | ||
| 24 | |||
| 17 | function normalizeHint(type?: string): string { | 25 | function normalizeHint(type?: string): string { |
| 18 | return type && type.trim().length > 0 ? type : "unknown"; | 26 | return type && type.trim().length > 0 ? type : "unknown"; |
| 19 | } | 27 | } |
| 20 | 28 | ||
| 21 | function isEmptyObjectSchema(schema: Record<string, unknown>): boolean { | 29 | function isEmptyObjectSchema(schema: Record<string, unknown>): boolean { |
| 22 | if (Object.keys(schema).length === 0) return true; | 30 | if (Object.keys(schema).length === 0) return true; |
| 23 | const props = asRecord(schema.properties); | 31 | const props = coerceRecord(schema.properties); |
| 24 | const required = Array.isArray(schema.required) ? schema.required : []; | 32 | const required = Array.isArray(schema.required) ? schema.required : []; |
| 25 | return Object.keys(props).length === 0 && required.length === 0; | 33 | return Object.keys(props).length === 0 && required.length === 0; |
| 26 | } | 34 | } |
| @@ -104,18 +112,21 @@ export function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] { · 77 unmodified lines | |||
| 104 | const preferredPath = preferredToolPath(tool.path); | 112 | const preferredPath = preferredToolPath(tool.path); |
| 105 | const aliases = getPathAliases(tool.path); | 113 | const aliases = getPathAliases(tool.path); |
| 106 | const searchText = `${tool.path} ${preferredPath} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase(); | 114 | const searchText = `${tool.path} ${preferredPath} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase(); |
| 115 | const typing = tool.typing; | ||
| 107 | 116 | ||
| 108 | const inputSchema = asRecord(tool.typing?.inputSchema); | 117 | const inputSchema = coerceRecord(typing?.inputSchema); |
| 109 | const outputSchema = asRecord(tool.typing?.outputSchema); | 118 | const outputSchema = coerceRecord(typing?.outputSchema); |
| 110 | 119 | ||
| 111 | const safeInputSchema = sanitizeJsonSchemaForConvex(inputSchema); | 120 | const safeInputSchema = sanitizeJsonSchemaForConvex(inputSchema); |
| 112 | const safeOutputSchema = sanitizeJsonSchemaForConvex(outputSchema); | 121 | const safeOutputSchema = sanitizeJsonSchemaForConvex(outputSchema); |
| 113 | 122 | ||
| 114 | const requiredInputKeys = Array.isArray(tool.typing?.requiredInputKeys) | 123 | const parsedRequiredInputKeys = stringArraySchema.safeParse(typing?.requiredInputKeys); |
| 115 | ? tool.typing!.requiredInputKeys!.filter((value): value is string => typeof value === "string") | 124 | const requiredInputKeys = parsedRequiredInputKeys.success |
| 125 | ? parsedRequiredInputKeys.data | ||
| 116 | : extractTopLevelRequiredKeys(inputSchema); | 126 | : extractTopLevelRequiredKeys(inputSchema); |
| 117 | const previewInputKeys = Array.isArray(tool.typing?.previewInputKeys) | 127 | const parsedPreviewInputKeys = stringArraySchema.safeParse(typing?.previewInputKeys); |
| 118 | ? tool.typing!.previewInputKeys!.filter((value): value is string => typeof value === "string") | 128 | const previewInputKeys = parsedPreviewInputKeys.success |
| 129 | ? parsedPreviewInputKeys.data | ||
| 119 | : buildPreviewKeys(inputSchema); | 130 | : buildPreviewKeys(inputSchema); |
| 120 | 131 | ||
| 121 | const displayInputHint = normalizeHint( | 132 | const displayInputHint = normalizeHint( |
| @@ -19,7 +19,6 @@ import { sanitizeSegment } from "../tool/path-utils"; · 18 unmodified lines | |||
| 19 | import type { GraphqlToolSourceConfig } from "../tool/source-types"; | 19 | import type { GraphqlToolSourceConfig } from "../tool/source-types"; |
| 20 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils"; | 20 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils"; |
| 21 | import type { ToolDefinition } from "../types"; | 21 | import type { ToolDefinition } from "../types"; |
| 22 | import { asRecord } from "../utils"; | ||
| 23 | 22 | ||
| 24 | const gqlTypeRefSchema: z.ZodType<GqlTypeRef> = z.lazy(() => z.object({ | 23 | const gqlTypeRefSchema: z.ZodType<GqlTypeRef> = z.lazy(() => z.object({ |
| 25 | kind: z.string(), | 24 | kind: z.string(), |
| @@ -53,7 +52,7 @@ const gqlEnumValueSchema = z.object({ · 27 unmodified lines | |||
| 53 | description: z.string().nullable().optional().transform((value) => value ?? null), | 52 | description: z.string().nullable().optional().transform((value) => value ?? null), |
| 54 | }); | 53 | }); |
| 55 | 54 | ||
| 56 | const gqlTypeSchema: z.ZodType<GqlType> = z.object({ | 55 | const gqlTypeSchema = z.object({ |
| 57 | kind: z.string(), | 56 | kind: z.string(), |
| 58 | name: z.string(), | 57 | name: z.string(), |
| 59 | fields: z.array(gqlFieldSchema).nullable().optional().transform((value) => value ?? null), | 58 | fields: z.array(gqlFieldSchema).nullable().optional().transform((value) => value ?? null), |
| @@ -61,7 +60,7 @@ const gqlTypeSchema: z.ZodType<GqlType> = z.object({ · 1 unmodified line | |||
| 61 | enumValues: z.array(gqlEnumValueSchema).nullable().optional().transform((value) => value ?? null), | 60 | enumValues: z.array(gqlEnumValueSchema).nullable().optional().transform((value) => value ?? null), |
| 62 | }); | 61 | }); |
| 63 | 62 | ||
| 64 | const gqlSchemaSchema: z.ZodType<GqlSchema> = z.object({ | 63 | const gqlSchemaSchema = z.object({ |
| 65 | queryType: z.object({ name: z.string() }).nullable().optional().transform((value) => value ?? null), | 64 | 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), | 65 | mutationType: z.object({ name: z.string() }).nullable().optional().transform((value) => value ?? null), |
| 67 | types: z.array(gqlTypeSchema), | 66 | types: z.array(gqlTypeSchema), |
| @@ -72,6 +71,59 @@ const gqlIntrospectionResponseSchema = z.object({ · 4 unmodified lines | |||
| 72 | errors: z.array(z.unknown()).optional(), | 71 | errors: z.array(z.unknown()).optional(), |
| 73 | }); | 72 | }); |
| 74 | 73 | ||
| 74 | const graphqlObjectInputSchema = z.object({ | ||
| 75 | query: z.string().optional(), | ||
| 76 | variables: z.unknown().optional(), | ||
| 77 | }).catchall(z.unknown()); | ||
| 78 | |||
| 79 | const graphqlToolInputSchema = z.union([ | ||
| 80 | z.string(), | ||
| 81 | graphqlObjectInputSchema, | ||
| 82 | ]); | ||
| 83 | |||
| 84 | const recordSchema = z.record(z.unknown()); | ||
| 85 | |||
| 86 | function coerceRecord(value: unknown): Record<string, unknown> { | ||
| 87 | const parsed = recordSchema.safeParse(value); | ||
| 88 | return parsed.success ? parsed.data : {}; | ||
| 89 | } | ||
| 90 | |||
| 91 | function normalizeGraphqlToolInput(value: unknown): { | ||
| 92 | payload: Record<string, unknown>; | ||
| 93 | query: string; | ||
| 94 | variables: unknown; | ||
| 95 | hasExplicitQuery: boolean; | ||
| 96 | } { | ||
| 97 | const parsedInput = graphqlToolInputSchema.safeParse(value); | ||
| 98 | if (!parsedInput.success) { | ||
| 99 | const payload = coerceRecord(value); | ||
| 100 | return { | ||
| 101 | payload, | ||
| 102 | query: "", | ||
| 103 | variables: payload.variables, | ||
| 104 | hasExplicitQuery: false, | ||
| 105 | }; | ||
| 106 | } | ||
| 107 | |||
| 108 | if (typeof parsedInput.data === "string") { | ||
| 109 | const query = parsedInput.data.trim(); | ||
| 110 | return { | ||
| 111 | payload: { query: parsedInput.data }, | ||
| 112 | query, | ||
| 113 | variables: undefined, | ||
| 114 | hasExplicitQuery: query.length > 0, | ||
| 115 | }; | ||
| 116 | } | ||
| 117 | |||
| 118 | const query = (parsedInput.data.query ?? "").trim(); | ||
| 119 | return { | ||
| 120 | payload: parsedInput.data, | ||
| 121 | query, | ||
| 122 | variables: parsedInput.data.variables, | ||
| 123 | hasExplicitQuery: query.length > 0, | ||
| 124 | }; | ||
| 125 | } | ||
| 126 | |||
| 75 | const INTROSPECTION_QUERY = ` | 127 | const INTROSPECTION_QUERY = ` |
| 76 | query IntrospectionQuery { | 128 | query IntrospectionQuery { |
| 77 | __schema { | 129 | __schema { |
| @@ -220,7 +272,7 @@ function parseGraphqlIntrospectionSchema(payload: unknown): Result<GqlSchema, Er · 142 unmodified lines | |||
| 220 | } | 272 | } |
| 221 | 273 | ||
| 222 | function toGraphqlEnvelope(value: unknown): GraphqlExecutionEnvelope { | 274 | function toGraphqlEnvelope(value: unknown): GraphqlExecutionEnvelope { |
| 223 | const payload = asRecord(value); | 275 | const payload = coerceRecord(value); |
| 224 | return { | 276 | return { |
| 225 | data: Object.prototype.hasOwnProperty.call(payload, "data") ? payload.data : null, | 277 | data: Object.prototype.hasOwnProperty.call(payload, "data") ? payload.data : null, |
| 226 | errors: Array.isArray(payload.errors) ? payload.errors : [], | 278 | errors: Array.isArray(payload.errors) ? payload.errors : [], |
| @@ -248,15 +300,15 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 21 unmodified lines | |||
| 248 | throw new Error(`GraphQL introspection failed: HTTP ${introspectionResult.status}: ${text.slice(0, 300)}`); | 300 | throw new Error(`GraphQL introspection failed: HTTP ${introspectionResult.status}: ${text.slice(0, 300)}`); |
| 249 | } | 301 | } |
| 250 | 302 | ||
| 251 | const introspectionJsonResult = await Result.tryPromise(() => introspectionResult.json()); | 303 | let introspectionJson: unknown; |
| 252 | if (introspectionJsonResult.isErr()) { | 304 | try { |
| 253 | const message = introspectionJsonResult.error.cause instanceof Error | 305 | introspectionJson = await introspectionResult.json(); |
| 254 | ? introspectionJsonResult.error.cause.message | 306 | } catch (error) { |
| 255 | : String(introspectionJsonResult.error.cause); | 307 | const message = error instanceof Error ? error.message : String(error); |
| 256 | throw new Error(`GraphQL introspection response parse failed: ${message}`); | 308 | throw new Error(`GraphQL introspection response parse failed: ${message}`); |
| 257 | } | 309 | } |
| 258 | 310 | ||
| 259 | const schemaResult = parseGraphqlIntrospectionSchema(introspectionJsonResult.value); | 311 | const schemaResult = parseGraphqlIntrospectionSchema(introspectionJson); |
| 260 | if (schemaResult.isErr()) { | 312 | if (schemaResult.isErr()) { |
| 261 | throw schemaResult.error; | 313 | throw schemaResult.error; |
| 262 | } | 314 | } |
| @@ -306,18 +358,15 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 43 unmodified lines | |||
| 306 | authHeaders, | 358 | authHeaders, |
| 307 | }, | 359 | }, |
| 308 | run: async (input: unknown, context) => { | 360 | run: async (input: unknown, context) => { |
| 309 | const payload = asRecord(input); | 361 | const normalized = normalizeGraphqlToolInput(input); |
| 310 | const query = String(payload.query ?? ""); | 362 | if (!normalized.hasExplicitQuery) { |
| 311 | const variables = payload.variables ?? undefined; | ||
| 312 | |||
| 313 | if (!query.trim()) { | ||
| 314 | throw new Error("GraphQL query string is required"); | 363 | throw new Error("GraphQL query string is required"); |
| 315 | } | 364 | } |
| 316 | return await executeGraphqlRequest( | 365 | return await executeGraphqlRequest( |
| 317 | config.endpoint, | 366 | config.endpoint, |
| 318 | authHeaders, | 367 | authHeaders, |
| 319 | query, | 368 | normalized.query, |
| 320 | variables, | 369 | normalized.variables, |
| 321 | context.credential?.headers, | 370 | context.credential?.headers, |
| 322 | ); | 371 | ); |
| 323 | }, | 372 | }, |
| @@ -389,9 +438,9 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 65 unmodified lines | |||
| 389 | _pseudoTool: true, | 438 | _pseudoTool: true, |
| 390 | run: async (input: unknown, context) => { | 439 | run: async (input: unknown, context) => { |
| 391 | // If someone calls this directly, delegate to the main graphql tool | 440 | // If someone calls this directly, delegate to the main graphql tool |
| 392 | const payload = asRecord(input); | 441 | const normalized = normalizeGraphqlToolInput(input); |
| 393 | const hasExplicitQuery = typeof payload.query === "string" && payload.query.trim().length > 0; | 442 | const payload = normalized.payload; |
| 394 | if (!hasExplicitQuery) { | 443 | if (!normalized.hasExplicitQuery) { |
| 395 | // Auto-build the query from the variables | 444 | // Auto-build the query from the variables |
| 396 | payload.query = buildFieldQuery(operationType, field.name, field.args, field.type, typeMap); | 445 | payload.query = buildFieldQuery(operationType, field.name, field.args, field.type, typeMap); |
| 397 | if (payload.variables === undefined) { | 446 | if (payload.variables === undefined) { |
| @@ -8,13 +8,19 @@ import { sanitizeSegment } from "../tool/path-utils"; · 7 unmodified lines | |||
| 8 | import type { McpToolSourceConfig } from "../tool/source-types"; | 8 | import type { McpToolSourceConfig } from "../tool/source-types"; |
| 9 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils"; | 9 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils"; |
| 10 | import type { ToolDefinition } from "../types"; | 10 | import type { ToolDefinition } from "../types"; |
| 11 | import { asRecord } from "../utils"; | ||
| 12 | import type { SerializedTool } from "../tool/source-serialization"; | 11 | import type { SerializedTool } from "../tool/source-serialization"; |
| 13 | 12 | ||
| 14 | const listedToolsResponseSchema = z.object({ | 13 | const listedToolsResponseSchema = z.object({ |
| 15 | tools: z.array(z.record(z.unknown())).optional(), | 14 | tools: z.array(z.record(z.unknown())).optional(), |
| 16 | }); | 15 | }); |
| 17 | 16 | ||
| 17 | const recordSchema = z.record(z.unknown()); | ||
| 18 | |||
| 19 | function coerceRecord(value: unknown): Record<string, unknown> { | ||
| 20 | const parsed = recordSchema.safeParse(value); | ||
| 21 | return parsed.success ? parsed.data : {}; | ||
| 22 | } | ||
| 23 | |||
| 18 | function extractListedTools(value: unknown): Record<string, unknown>[] { | 24 | function extractListedTools(value: unknown): Record<string, unknown>[] { |
| 19 | const parsed = listedToolsResponseSchema.safeParse(value); | 25 | const parsed = listedToolsResponseSchema.safeParse(value); |
| 20 | if (!parsed.success) { | 26 | if (!parsed.success) { |
| @@ -68,8 +74,8 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 47 unmodified lines | |||
| 68 | 74 | ||
| 69 | return tools.map((tool) => { | 75 | return tools.map((tool) => { |
| 70 | const toolName = String(tool.name ?? "tool"); | 76 | const toolName = String(tool.name ?? "tool"); |
| 71 | const inputSchema = asRecord(tool.inputSchema); | 77 | const inputSchema = coerceRecord(tool.inputSchema); |
| 72 | const outputSchema = asRecord(tool.outputSchema); | 78 | const outputSchema = coerceRecord(tool.outputSchema); |
| 73 | const previewInputKeys = buildPreviewKeys(inputSchema).filter((key) => key.length > 0); | 79 | const previewInputKeys = buildPreviewKeys(inputSchema).filter((key) => key.length > 0); |
| 74 | const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema); | 80 | const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema); |
| 75 | return { | 81 | return { |
| @@ -93,7 +99,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 17 unmodified lines | |||
| 93 | toolName, | 99 | toolName, |
| 94 | }, | 100 | }, |
| 95 | run: async (input: unknown, context) => { | 101 | run: async (input: unknown, context) => { |
| 96 | const payload = asRecord(input); | 102 | const payload = coerceRecord(input); |
| 97 | const result = await callToolWithReconnect(toolName, payload, context.credential?.headers); | 103 | const result = await callToolWithReconnect(toolName, payload, context.credential?.headers); |
| 98 | return extractMcpResult(result); | 104 | return extractMcpResult(result); |
| 99 | }, | 105 | }, |
| @@ -1,6 +1,5 @@ | |||
| 1 | "use node"; | 1 | "use node"; |
| 2 | 2 | ||
| 3 | import { Result } from "better-result"; | ||
| 4 | import { z } from "zod"; | 3 | import { z } from "zod"; |
| 5 | import { buildOpenApiToolsFromPrepared } from "../openapi/tool-builder"; | 4 | import { buildOpenApiToolsFromPrepared } from "../openapi/tool-builder"; |
| 6 | import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth"; | 5 | import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth"; |
| @@ -16,7 +15,7 @@ import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman · 9 unmodified lines | |||
| 16 | import { prepareOpenApiSpec } from "../openapi-prepare"; | 15 | import { prepareOpenApiSpec } from "../openapi-prepare"; |
| 17 | import type { OpenApiToolSourceConfig } from "../tool/source-types"; | 16 | import type { OpenApiToolSourceConfig } from "../tool/source-types"; |
| 18 | import type { ToolDefinition } from "../types"; | 17 | import type { ToolDefinition } from "../types"; |
| 19 | import { asRecord } from "../utils"; | 18 | import { toPlainObject } from "../utils"; |
| 20 | import type { SerializedTool } from "../tool/source-serialization"; | 19 | import type { SerializedTool } from "../tool/source-serialization"; |
| 21 | 20 | ||
| 22 | const POSTMAN_SPEC_PREFIX = "postman:"; | 21 | const POSTMAN_SPEC_PREFIX = "postman:"; |
| @@ -30,6 +29,29 @@ const postmanCollectionResponseSchema = z.object({ · 7 unmodified lines | |||
| 30 | }).optional(), | 29 | }).optional(), |
| 31 | }); | 30 | }); |
| 32 | 31 | ||
| 32 | const postmanFolderSchema = z.object({ | ||
| 33 | id: z.string(), | ||
| 34 | name: z.string().optional(), | ||
| 35 | folder: z.string().optional(), | ||
| 36 | }); | ||
| 37 | |||
| 38 | const postmanRequestSchema = z.object({ | ||
| 39 | id: z.string().optional(), | ||
| 40 | name: z.string().optional(), | ||
| 41 | method: z.string().optional(), | ||
| 42 | url: z.string(), | ||
| 43 | folder: z.string().optional(), | ||
| 44 | pathVariableData: z.unknown().optional(), | ||
| 45 | pathVariables: z.unknown().optional(), | ||
| 46 | headerData: z.unknown().optional(), | ||
| 47 | headers: z.unknown().optional(), | ||
| 48 | queryParams: z.unknown().optional(), | ||
| 49 | queryParam: z.unknown().optional(), | ||
| 50 | bodyData: z.unknown().optional(), | ||
| 51 | body: z.unknown().optional(), | ||
| 52 | description: z.string().optional(), | ||
| 53 | }); | ||
| 54 | |||
| 33 | function parsePostmanCollectionUid(spec: string): string | null { | 55 | function parsePostmanCollectionUid(spec: string): string | null { |
| 34 | if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) { | 56 | if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) { |
| 35 | return null; | 57 | return null; |
| @@ -67,14 +89,15 @@ async function loadPostmanCollectionTools( · 31 unmodified lines | |||
| 67 | throw new Error(`Failed to fetch API collection ${collectionUid}: HTTP ${response.status} ${text.slice(0, 300)}`); | 89 | throw new Error(`Failed to fetch API collection ${collectionUid}: HTTP ${response.status} ${text.slice(0, 300)}`); |
| 68 | } | 90 | } |
| 69 | 91 | ||
| 70 | const responseJsonResult = await Result.tryPromise(() => response.json()); | 92 | let responseJson: unknown; |
| 71 | if (responseJsonResult.isErr()) { | 93 | try { |
| 72 | const cause = responseJsonResult.error.cause; | 94 | responseJson = await response.json(); |
| 73 | const message = cause instanceof Error ? cause.message : String(cause); | 95 | } catch (error) { |
| 96 | const message = error instanceof Error ? error.message : String(error); | ||
| 74 | throw new Error(`Failed to parse API collection ${collectionUid}: ${message}`); | 97 | throw new Error(`Failed to parse API collection ${collectionUid}: ${message}`); |
| 75 | } | 98 | } |
| 76 | 99 | ||
| 77 | const parsedCollection = postmanCollectionResponseSchema.safeParse(responseJsonResult.value); | 100 | const parsedCollection = postmanCollectionResponseSchema.safeParse(responseJson); |
| 78 | if (!parsedCollection.success) { | 101 | if (!parsedCollection.success) { |
| 79 | throw new Error(`Invalid API collection response for ${collectionUid}: ${parsedCollection.error.message}`); | 102 | throw new Error(`Invalid API collection response for ${collectionUid}: ${parsedCollection.error.message}`); |
| 80 | } | 103 | } |
| @@ -87,11 +110,14 @@ async function loadPostmanCollectionTools( · 6 unmodified lines | |||
| 87 | 110 | ||
| 88 | const folderById = new Map<string, { name: string; parentId?: string }>(); | 111 | const folderById = new Map<string, { name: string; parentId?: string }>(); |
| 89 | for (const folder of folders) { | 112 | for (const folder of folders) { |
| 90 | const id = typeof folder.id === "string" ? folder.id : ""; | 113 | const parsedFolder = postmanFolderSchema.safeParse(folder); |
| 91 | if (!id) continue; | 114 | if (!parsedFolder.success) continue; |
| 92 | const name = typeof folder.name === "string" && folder.name.trim().length > 0 ? folder.name : "folder"; | 115 | |
| 93 | const parentId = typeof folder.folder === "string" ? folder.folder : undefined; | 116 | const id = parsedFolder.data.id; |
| 94 | folderById.set(id, { name, parentId }); | 117 | const name = parsedFolder.data.name && parsedFolder.data.name.trim().length > 0 |
| 118 | ? parsedFolder.data.name | ||
| 119 | : "folder"; | ||
| 120 | folderById.set(id, { name, parentId: parsedFolder.data.folder }); | ||
| 95 | } | 121 | } |
| 96 | 122 | ||
| 97 | const sourceLabel = `catalog:${config.name}`; | 123 | const sourceLabel = `catalog:${config.name}`; |
| @@ -115,29 +141,32 @@ async function loadPostmanCollectionTools( · 17 unmodified lines | |||
| 115 | const tools: ToolDefinition[] = []; | 141 | const tools: ToolDefinition[] = []; |
| 116 | 142 | ||
| 117 | for (const request of requests) { | 143 | for (const request of requests) { |
| 118 | const methodRaw = typeof request.method === "string" ? request.method.toLowerCase() : "get"; | 144 | const parsedRequest = postmanRequestSchema.safeParse(request); |
| 145 | if (!parsedRequest.success) continue; | ||
| 146 | |||
| 147 | const methodRaw = (parsedRequest.data.method ?? "get").toLowerCase(); | ||
| 119 | const method = methodRaw.length > 0 ? methodRaw : "get"; | 148 | const method = methodRaw.length > 0 ? methodRaw : "get"; |
| 120 | const url = typeof request.url === "string" ? request.url : ""; | 149 | const url = parsedRequest.data.url; |
| 121 | if (!url) continue; | 150 | if (!url) continue; |
| 122 | 151 | ||
| 123 | const requestId = typeof request.id === "string" ? request.id : ""; | 152 | const requestId = parsedRequest.data.id ?? ""; |
| 124 | const requestName = typeof request.name === "string" && request.name.trim().length > 0 | 153 | const requestName = parsedRequest.data.name && parsedRequest.data.name.trim().length > 0 |
| 125 | ? request.name.trim() | 154 | ? parsedRequest.data.name.trim() |
| 126 | : requestId || `${method.toUpperCase()} request`; | 155 | : requestId || `${method.toUpperCase()} request`; |
| 127 | const folderId = typeof request.folder === "string" ? request.folder : undefined; | 156 | const folderId = parsedRequest.data.folder; |
| 128 | const folderPath = resolvePostmanFolderPath(folderId, folderById); | 157 | const folderPath = resolvePostmanFolderPath(folderId, folderById); |
| 129 | const requestVariables = { | 158 | const requestVariables = { |
| 130 | ...collectionVariables, | 159 | ...collectionVariables, |
| 131 | ...extractPostmanVariableMap(request.pathVariableData), | 160 | ...extractPostmanVariableMap(parsedRequest.data.pathVariableData ?? parsedRequest.data.pathVariables), |
| 132 | }; | 161 | }; |
| 133 | 162 | ||
| 134 | const runSpec: PostmanSerializedRunSpec = { | 163 | const runSpec: PostmanSerializedRunSpec = { |
| 135 | kind: "postman", | 164 | kind: "postman", |
| 136 | method, | 165 | method, |
| 137 | url, | 166 | url, |
| 138 | headers: extractPostmanHeaderMap(request.headerData), | 167 | headers: extractPostmanHeaderMap(parsedRequest.data.headerData ?? parsedRequest.data.headers), |
| 139 | queryParams: extractPostmanQueryEntries(request.queryParams), | 168 | queryParams: extractPostmanQueryEntries(parsedRequest.data.queryParams ?? parsedRequest.data.queryParam), |
| 140 | body: extractPostmanBody(request), | 169 | body: extractPostmanBody(parsedRequest.data.bodyData ?? parsedRequest.data.body), |
| 141 | variables: requestVariables, | 170 | variables: requestVariables, |
| 142 | authHeaders, | 171 | authHeaders, |
| 143 | }; | 172 | }; |
| @@ -152,8 +181,8 @@ async function loadPostmanCollectionTools( · 8 unmodified lines | |||
| 152 | path: buildPostmanToolPath(config.name, requestName, folderPath, usedPaths), | 181 | path: buildPostmanToolPath(config.name, requestName, folderPath, usedPaths), |
| 153 | source: sourceLabel, | 182 | source: sourceLabel, |
| 154 | approval, | 183 | approval, |
| 155 | description: typeof request.description === "string" && request.description.trim().length > 0 | 184 | description: parsedRequest.data.description && parsedRequest.data.description.trim().length > 0 |
| 156 | ? request.description | 185 | ? parsedRequest.data.description |
| 157 | : `${method.toUpperCase()} ${url}`, | 186 | : `${method.toUpperCase()} ${url}`, |
| 158 | typing: { | 187 | typing: { |
| 159 | inputSchema, | 188 | inputSchema, |
| @@ -163,7 +192,7 @@ async function loadPostmanCollectionTools( · 3 unmodified lines | |||
| 163 | credential: credentialSpec, | 192 | credential: credentialSpec, |
| 164 | _runSpec: runSpec, | 193 | _runSpec: runSpec, |
| 165 | run: async (input: unknown, context) => { | 194 | run: async (input: unknown, context) => { |
| 166 | const payloadRecord = asRecord(input); | 195 | const payloadRecord = toPlainObject(input) ?? {}; |
| 167 | return await executePostmanRequest(runSpec, payloadRecord, context.credential?.headers); | 196 | return await executePostmanRequest(runSpec, payloadRecord, context.credential?.headers); |
| 168 | }, | 197 | }, |
| 169 | }; | 198 | }; |
| @@ -448,6 +448,87 @@ test("postman collection specs load and execute request tools", async () => { · 447 unmodified lines | |||
| 448 | } | 448 | } |
| 449 | }); | 449 | }); |
| 450 | 450 | ||
| 451 | test("postman collection supports v2 body modes", async () => { | ||
| 452 | const upstream = Bun.serve({ | ||
| 453 | port: 0, | ||
| 454 | fetch: async (req) => { | ||
| 455 | const url = new URL(req.url); | ||
| 456 | if (req.method === "POST" && url.pathname === "/submit") { | ||
| 457 | return Response.json({ | ||
| 458 | ok: true, | ||
| 459 | body: await req.text(), | ||
| 460 | contentType: req.headers.get("content-type"), | ||
| 461 | token: req.headers.get("x-token"), | ||
| 462 | }); | ||
| 463 | } | ||
| 464 | return new Response("not found", { status: 404 }); | ||
| 465 | }, | ||
| 466 | }); | ||
| 467 | |||
| 468 | const proxy = Bun.serve({ | ||
| 469 | port: 0, | ||
| 470 | fetch: async (req) => { | ||
| 471 | const body = (await req.json()) as { service?: string; method?: string; path?: string }; | ||
| 472 | if (body.service === "sync" && body.method === "GET" && body.path === "/collection/123-v2-body?populate=true") { | ||
| 473 | return Response.json({ | ||
| 474 | data: { | ||
| 475 | name: "Demo v2 body collection", | ||
| 476 | variables: [{ key: "token", value: "Bearer v2-token" }], | ||
| 477 | requests: [ | ||
| 478 | { | ||
| 479 | id: "request_submit", | ||
| 480 | name: "Submit payload", | ||
| 481 | method: "POST", | ||
| 482 | url: `http://127.0.0.1:${upstream.port}/submit`, | ||
| 483 | headerData: [{ key: "x-token", value: "{{token}}" }], | ||
| 484 | body: { | ||
| 485 | mode: "urlencoded", | ||
| 486 | urlencoded: [ | ||
| 487 | { key: "itemId", value: "{{itemId}}" }, | ||
| 488 | { key: "count", value: 2 }, | ||
| 489 | ], | ||
| 490 | }, | ||
| 491 | }, | ||
| 492 | ], | ||
| 493 | }, | ||
| 494 | }); | ||
| 495 | } | ||
| 496 | return Response.json({ error: "unknown request" }, { status: 400 }); | ||
| 497 | }, | ||
| 498 | }); | ||
| 499 | |||
| 500 | try { | ||
| 501 | const { tools, warnings } = await loadExternalTools([ | ||
| 502 | { | ||
| 503 | type: "openapi", | ||
| 504 | name: "postman-v2-body", | ||
| 505 | spec: "postman:123-v2-body", | ||
| 506 | postmanProxyUrl: `http://127.0.0.1:${proxy.port}`, | ||
| 507 | }, | ||
| 508 | ]); | ||
| 509 | |||
| 510 | expect(warnings).toHaveLength(0); | ||
| 511 | expect(tools).toHaveLength(1); | ||
| 512 | |||
| 513 | const result = await tools[0]!.run( | ||
| 514 | { | ||
| 515 | variables: { itemId: "xyz" }, | ||
| 516 | }, | ||
| 517 | { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true }, | ||
| 518 | ); | ||
| 519 | |||
| 520 | expect(result).toEqual({ | ||
| 521 | ok: true, | ||
| 522 | body: "itemId=xyz&count=2", | ||
| 523 | contentType: "application/x-www-form-urlencoded", | ||
| 524 | token: "Bearer v2-token", | ||
| 525 | }); | ||
| 526 | } finally { | ||
| 527 | proxy.stop(true); | ||
| 528 | upstream.stop(true); | ||
| 529 | } | ||
| 530 | }); | ||
| 531 | |||
| 451 | test("parseGraphqlOperationPaths handles aliases and fragments", async () => { | 532 | test("parseGraphqlOperationPaths handles aliases and fragments", async () => { |
| 452 | const parsed = parseGraphqlOperationPaths( | 533 | const parsed = parseGraphqlOperationPaths( |
| 453 | "linear", | 534 | "linear", |
| @@ -708,6 +789,7 @@ test("graphql helper tools generate valid selection sets and envelope responses" · 254 unmodified lines | |||
| 708 | const batchResult = await batchTool!.run({ input: { issues: ["one"] } }, context); | 789 | const batchResult = await batchTool!.run({ input: { issues: ["one"] } }, context); |
| 709 | const batchResultDoubleWrapped = await batchTool!.run({ input: { input: { issues: ["two"] } } }, context); | 790 | const batchResultDoubleWrapped = await batchTool!.run({ input: { input: { issues: ["two"] } } }, context); |
| 710 | const rawResult = await rawTool!.run({ query: "query { teams { totalCount } }" }, context); | 791 | const rawResult = await rawTool!.run({ query: "query { teams { totalCount } }" }, context); |
| 792 | const rawStringResult = await rawTool!.run("query { teams { totalCount } }", context); | ||
| 711 | 793 | ||
| 712 | expect(teamsResult).toEqual({ | 794 | expect(teamsResult).toEqual({ |
| 713 | data: { | 795 | data: { |
| @@ -739,6 +821,15 @@ test("graphql helper tools generate valid selection sets and envelope responses" · 25 unmodified lines | |||
| 739 | }, | 821 | }, |
| 740 | errors: [], | 822 | errors: [], |
| 741 | }); | 823 | }); |
| 824 | expect(rawStringResult).toEqual({ | ||
| 825 | data: { | ||
| 826 | teams: { | ||
| 827 | totalCount: 1, | ||
| 828 | nodes: [{ id: "team_1", name: "Core" }], | ||
| 829 | }, | ||
| 830 | }, | ||
| 831 | errors: [], | ||
| 832 | }); | ||
| 742 | 833 | ||
| 743 | const helperQueries = capturedQueries.filter((query) => !query.includes("__schema")); | 834 | const helperQueries = capturedQueries.filter((query) => !query.includes("__schema")); |
| 744 | const helperVariables = capturedVariables.slice(1); | 835 | const helperVariables = capturedVariables.slice(1); |
| @@ -1,5 +1,9 @@ | |||
| 1 | import type { JsonSchema } from "../types"; | 1 | import type { JsonSchema } from "../types"; |
| 2 | import { asRecord } from "../utils"; | 2 | import { isPlainObject } from "../utils"; |
| 3 | |||
| 4 | function toRecord(value: unknown): Record<string, unknown> { | ||
| 5 | return isPlainObject(value) ? value : {}; | ||
| 6 | } | ||
| 3 | 7 | ||
| 4 | function stripDollarKeysDeep(value: unknown): unknown { | 8 | function stripDollarKeysDeep(value: unknown): unknown { |
| 5 | if (Array.isArray(value)) { | 9 | if (Array.isArray(value)) { |
| @@ -8,7 +12,7 @@ function stripDollarKeysDeep(value: unknown): unknown { · 2 unmodified lines | |||
| 8 | 12 | ||
| 9 | if (value && typeof value === "object") { | 13 | if (value && typeof value === "object") { |
| 10 | const out: Record<string, unknown> = {}; | 14 | const out: Record<string, unknown> = {}; |
| 11 | for (const [key, child] of Object.entries(asRecord(value))) { | 15 | for (const [key, child] of Object.entries(toRecord(value))) { |
| 12 | if (key.startsWith("$")) continue; | 16 | if (key.startsWith("$")) continue; |
| 13 | out[key] = stripDollarKeysDeep(child); | 17 | out[key] = stripDollarKeysDeep(child); |
| 14 | } | 18 | } |
| @@ -1,5 +1,9 @@ | |||
| 1 | import type { JsonSchema } from "../types"; | 1 | import type { JsonSchema } from "../types"; |
| 2 | import { asRecord } from "../utils"; | 2 | import { isPlainObject } from "../utils"; |
| 3 | |||
| 4 | function toRecord(value: unknown): Record<string, unknown> { | ||
| 5 | return isPlainObject(value) ? value : {}; | ||
| 6 | } | ||
| 3 | 7 | ||
| 4 | function uniq(values: string[]): string[] { | 8 | function uniq(values: string[]): string[] { |
| 5 | return [...new Set(values.filter((v) => v.trim().length > 0))]; | 9 | return [...new Set(values.filter((v) => v.trim().length > 0))]; |
| @@ -16,19 +20,19 @@ function collectTopLevelRequiredKeys(schema: Record<string, unknown>, out: strin · 10 unmodified lines | |||
| 16 | 20 | ||
| 17 | const allOf = asUnknownArray(schema.allOf); | 21 | const allOf = asUnknownArray(schema.allOf); |
| 18 | for (const entry of allOf) { | 22 | for (const entry of allOf) { |
| 19 | const nested = asRecord(entry); | 23 | const nested = toRecord(entry); |
| 20 | if (Object.keys(nested).length === 0) continue; | 24 | if (Object.keys(nested).length === 0) continue; |
| 21 | collectTopLevelRequiredKeys(nested, out); | 25 | collectTopLevelRequiredKeys(nested, out); |
| 22 | } | 26 | } |
| 23 | } | 27 | } |
| 24 | 28 | ||
| 25 | function collectTopLevelPropertyKeys(schema: Record<string, unknown>, out: string[]): void { | 29 | function collectTopLevelPropertyKeys(schema: Record<string, unknown>, out: string[]): void { |
| 26 | const props = asRecord(schema.properties); | 30 | const props = toRecord(schema.properties); |
| 27 | out.push(...Object.keys(props)); | 31 | out.push(...Object.keys(props)); |
| 28 | 32 | ||
| 29 | const allOf = asUnknownArray(schema.allOf); | 33 | const allOf = asUnknownArray(schema.allOf); |
| 30 | for (const entry of allOf) { | 34 | for (const entry of allOf) { |
| 31 | const nested = asRecord(entry); | 35 | const nested = toRecord(entry); |
| 32 | if (Object.keys(nested).length === 0) continue; | 36 | if (Object.keys(nested).length === 0) continue; |
| 33 | collectTopLevelPropertyKeys(nested, out); | 37 | collectTopLevelPropertyKeys(nested, out); |
| 34 | } | 38 | } |
| @@ -1,9 +1,13 @@ | |||
| 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 { isPlainObject } from "../utils"; |
| 5 | import { BASE_ENVIRONMENT_DTS } from "./env-types"; | 5 | import { BASE_ENVIRONMENT_DTS } from "./env-types"; |
| 6 | 6 | ||
| 7 | function toRecord(value: unknown): Record<string, unknown> { | ||
| 8 | return isPlainObject(value) ? value : {}; | ||
| 9 | } | ||
| 10 | |||
| 7 | const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i; | 11 | const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i; |
| 8 | 12 | ||
| 9 | function emitMemberName(name: string): string { | 13 | function emitMemberName(name: string): string { |
| @@ -31,7 +35,7 @@ function wrapDtsInNamespace(namespace: string, rawDts: string): string { · 21 unmodified lines | |||
| 31 | function typeHintFromSchema(schema: Record<string, unknown> | undefined, fallback: string): string { | 35 | function typeHintFromSchema(schema: Record<string, unknown> | undefined, fallback: string): string { |
| 32 | if (!schema || Object.keys(schema).length === 0) return fallback; | 36 | if (!schema || Object.keys(schema).length === 0) return fallback; |
| 33 | // Special-case empty object input to keep signatures tidy. | 37 | // Special-case empty object input to keep signatures tidy. |
| 34 | const props = asRecord(schema.properties); | 38 | const props = toRecord(schema.properties); |
| 35 | const required = Array.isArray(schema.required) ? schema.required : []; | 39 | const required = Array.isArray(schema.required) ? schema.required : []; |
| 36 | if (Object.keys(props).length === 0 && required.length === 0) { | 40 | if (Object.keys(props).length === 0 && required.length === 0) { |
| 37 | return fallback === "{}" ? "{}" : fallback; | 41 | return fallback === "{}" ? "{}" : fallback; |
| @@ -0,0 +1,77 @@ | |||
| 1 | import { expect, test } from "bun:test"; | ||
| 2 | import { | ||
| 3 | buildCredentialAuthHeaders, | ||
| 4 | buildCredentialSpec, | ||
| 5 | buildStaticAuthHeaders, | ||
| 6 | readCredentialOverrideHeaders, | ||
| 7 | } from "./source-auth"; | ||
| 8 | |||
| 9 | test("buildStaticAuthHeaders returns static bearer header", () => { | ||
| 10 | const headers = buildStaticAuthHeaders({ | ||
| 11 | type: "bearer", | ||
| 12 | mode: "static", | ||
| 13 | token: "secret-token", | ||
| 14 | }); | ||
| 15 | |||
| 16 | expect(headers).toEqual({ authorization: "Bearer secret-token" }); | ||
| 17 | }); | ||
| 18 | |||
| 19 | test("buildCredentialSpec omits static auth and preserves actor mode", () => { | ||
| 20 | expect(buildCredentialSpec("source:test", { type: "none" })).toBeUndefined(); | ||
| 21 | expect( | ||
| 22 | buildCredentialSpec("source:test", { | ||
| 23 | type: "apiKey", | ||
| 24 | mode: "actor", | ||
| 25 | header: "x-api-key", | ||
| 26 | }), | ||
| 27 | ).toEqual({ | ||
| 28 | sourceKey: "source:test", | ||
| 29 | mode: "actor", | ||
| 30 | authType: "apiKey", | ||
| 31 | headerName: "x-api-key", | ||
| 32 | }); | ||
| 33 | }); | ||
| 34 | |||
| 35 | test("buildCredentialAuthHeaders supports bearer token aliases", () => { | ||
| 36 | const headers = buildCredentialAuthHeaders( | ||
| 37 | { authType: "bearer" }, | ||
| 38 | { accessToken: " token-123 " }, | ||
| 39 | ); | ||
| 40 | |||
| 41 | expect(headers).toEqual({ authorization: "Bearer token-123" }); | ||
| 42 | }); | ||
| 43 | |||
| 44 | test("buildCredentialAuthHeaders supports apiKey aliases and explicit header", () => { | ||
| 45 | const headers = buildCredentialAuthHeaders( | ||
| 46 | { authType: "apiKey", headerName: "x-custom-key" }, | ||
| 47 | { apiKey: "api-value", headerName: "x-ignored" }, | ||
| 48 | ); | ||
| 49 | |||
| 50 | expect(headers).toEqual({ "x-custom-key": "api-value" }); | ||
| 51 | }); | ||
| 52 | |||
| 53 | test("buildCredentialAuthHeaders supports basic auth aliases", () => { | ||
| 54 | const headers = buildCredentialAuthHeaders( | ||
| 55 | { authType: "basic" }, | ||
| 56 | { user: "alice", pass: "hunter2" }, | ||
| 57 | ); | ||
| 58 | |||
| 59 | expect(headers).toEqual({ | ||
| 60 | authorization: `Basic ${Buffer.from("alice:hunter2", "utf8").toString("base64")}`, | ||
| 61 | }); | ||
| 62 | }); | ||
| 63 | |||
| 64 | test("readCredentialOverrideHeaders trims keys and coerces values", () => { | ||
| 65 | const headers = readCredentialOverrideHeaders({ | ||
| 66 | headers: { | ||
| 67 | " x-trace-id ": "trace-1", | ||
| 68 | "": "ignored", | ||
| 69 | "x-retry": 2, | ||
| 70 | }, | ||
| 71 | }); | ||
| 72 | |||
| 73 | expect(headers).toEqual({ | ||
| 74 | "x-trace-id": "trace-1", | ||
| 75 | "x-retry": "2", | ||
| 76 | }); | ||
| 77 | }); | ||
| @@ -1,5 +1,88 @@ | |||
| 1 | import type { OpenApiAuth } from "./source-types"; | 1 | import type { OpenApiAuth } from "./source-types"; |
| 2 | import type { ToolCredentialSpec } from "../types"; | 2 | import type { ToolCredentialSpec } from "../types"; |
| 3 | import { z } from "zod"; | ||
| 4 | |||
| 5 | export type CredentialHeaderAuthSpec = { | ||
| 6 | authType: "bearer" | "apiKey" | "basic"; | ||
| 7 | headerName?: string; | ||
| 8 | }; | ||
| 9 | |||
| 10 | const secretRecordSchema = z.record(z.unknown()); | ||
| 11 | const credentialOverrideHeadersSchema = z.object({ | ||
| 12 | headers: z.record(z.coerce.string()).optional(), | ||
| 13 | }); | ||
| 14 | |||
| 15 | function toRecord(value: unknown): Record<string, unknown> { | ||
| 16 | const parsed = secretRecordSchema.safeParse(value); | ||
| 17 | return parsed.success ? parsed.data : {}; | ||
| 18 | } | ||
| 19 | |||
| 20 | function getTrimmedString(value: unknown): string { | ||
| 21 | if (typeof value !== "string") { | ||
| 22 | return ""; | ||
| 23 | } | ||
| 24 | |||
| 25 | return value.trim(); | ||
| 26 | } | ||
| 27 | |||
| 28 | function readSecretValue(record: Record<string, unknown>, aliases: string[]): string { | ||
| 29 | const entries = new Map<string, unknown>(); | ||
| 30 | for (const [key, value] of Object.entries(record)) { | ||
| 31 | entries.set(key.toLowerCase(), value); | ||
| 32 | } | ||
| 33 | |||
| 34 | for (const alias of aliases) { | ||
| 35 | const value = entries.get(alias.toLowerCase()); | ||
| 36 | const trimmed = getTrimmedString(value); | ||
| 37 | if (trimmed) { | ||
| 38 | return trimmed; | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | return ""; | ||
| 43 | } | ||
| 44 | |||
| 45 | export function buildCredentialAuthHeaders( | ||
| 46 | auth: CredentialHeaderAuthSpec, | ||
| 47 | secret: unknown, | ||
| 48 | ): Record<string, string> { | ||
| 49 | const payload = toRecord(secret); | ||
| 50 | |||
| 51 | if (auth.authType === "bearer") { | ||
| 52 | const token = readSecretValue(payload, ["token", "accessToken", "bearerToken", "value"]); | ||
| 53 | return token ? { authorization: `Bearer ${token}` } : {}; | ||
| 54 | } | ||
| 55 | |||
| 56 | if (auth.authType === "apiKey") { | ||
| 57 | const discoveredHeader = readSecretValue(payload, ["headerName", "header", "keyName"]); | ||
| 58 | const headerName = (auth.headerName ?? discoveredHeader) || "x-api-key"; | ||
| 59 | const value = readSecretValue(payload, ["value", "token", "apiKey", "key", "accessToken"]); | ||
| 60 | return value ? { [headerName]: value } : {}; | ||
| 61 | } | ||
| 62 | |||
| 63 | const username = readSecretValue(payload, ["username", "user"]); | ||
| 64 | const password = readSecretValue(payload, ["password", "pass"]); | ||
| 65 | if (!username && !password) { | ||
| 66 | return {}; | ||
| 67 | } | ||
| 68 | |||
| 69 | const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); | ||
| 70 | return { authorization: `Basic ${encoded}` }; | ||
| 71 | } | ||
| 72 | |||
| 73 | export function readCredentialOverrideHeaders(value: unknown): Record<string, string> { | ||
| 74 | const parsed = credentialOverrideHeadersSchema.safeParse(value); | ||
| 75 | const rawHeaders = parsed.success ? (parsed.data.headers ?? {}) : {}; | ||
| 76 | |||
| 77 | const normalized: Record<string, string> = {}; | ||
| 78 | for (const [rawKey, rawValue] of Object.entries(rawHeaders)) { | ||
| 79 | const key = rawKey.trim(); | ||
| 80 | if (!key) continue; | ||
| 81 | normalized[key] = rawValue; | ||
| 82 | } | ||
| 83 | |||
| 84 | return normalized; | ||
| 85 | } | ||
| 3 | 86 | ||
| 4 | export function buildStaticAuthHeaders(auth?: OpenApiAuth): Record<string, string> { | 87 | export function buildStaticAuthHeaders(auth?: OpenApiAuth): Record<string, string> { |
| 5 | if (!auth || auth.type === "none") return {}; | 88 | if (!auth || auth.type === "none") return {}; |
| @@ -1,5 +1,12 @@ | |||
| 1 | import { Result, TaggedError } from "better-result"; | 1 | import { Result, TaggedError } from "better-result"; |
| 2 | import { asRecord } from "../utils"; | 2 | import { z } from "zod"; |
| 3 | |||
| 4 | const recordSchema = z.record(z.unknown()); | ||
| 5 | |||
| 6 | function toRecord(value: unknown): Record<string, unknown> { | ||
| 7 | const parsed = recordSchema.safeParse(value); | ||
| 8 | return parsed.success ? parsed.data : {}; | ||
| 9 | } | ||
| 3 | 10 | ||
| 4 | type OpenApiRequestErrorArgs = { | 11 | type OpenApiRequestErrorArgs = { |
| 5 | status: number | null; | 12 | status: number | null; |
| @@ -88,7 +95,7 @@ export async function executeOpenApiRequest( · 82 unmodified lines | |||
| 88 | input: unknown, | 95 | input: unknown, |
| 89 | credentialHeaders?: Record<string, string>, | 96 | credentialHeaders?: Record<string, string>, |
| 90 | ): Promise<Result<unknown, OpenApiRequestError>> { | 97 | ): Promise<Result<unknown, OpenApiRequestError>> { |
| 91 | const payload = asRecord(input); | 98 | const payload = toRecord(input); |
| 92 | const readMethods = new Set(["get", "head", "options"]); | 99 | const readMethods = new Set(["get", "head", "options"]); |
| 93 | const { url, bodyInput } = buildOpenApiUrl( | 100 | const { url, bodyInput } = buildOpenApiUrl( |
| 94 | runSpec.baseUrl, | 101 | runSpec.baseUrl, |
| @@ -154,7 +161,7 @@ export async function executeOpenApiRequest( · 59 unmodified lines | |||
| 154 | function hasGraphqlData(data: unknown): boolean { | 161 | function hasGraphqlData(data: unknown): boolean { |
| 155 | if (data === null || data === undefined) return false; | 162 | if (data === null || data === undefined) return false; |
| 156 | if (Array.isArray(data)) return data.length > 0; | 163 | if (Array.isArray(data)) return data.length > 0; |
| 157 | if (typeof data === "object") return Object.keys(asRecord(data)).length > 0; | 164 | if (typeof data === "object") return Object.keys(toRecord(data)).length > 0; |
| 158 | return true; | 165 | return true; |
| 159 | } | 166 | } |
| 160 | 167 | ||
| @@ -5,7 +5,13 @@ import { callMcpToolWithReconnect, executeGraphqlRequest, executeOpenApiRequest · 4 unmodified lines | |||
| 5 | import { Result } from "better-result"; | 5 | import { Result } from "better-result"; |
| 6 | import { z } from "zod"; | 6 | import { z } from "zod"; |
| 7 | import type { ToolApprovalMode, ToolCredentialSpec, ToolDefinition, ToolTyping } from "../types"; | 7 | import type { ToolApprovalMode, ToolCredentialSpec, ToolDefinition, ToolTyping } from "../types"; |
| 8 | import { asRecord } from "../utils"; | 8 | |
| 9 | const recordSchema = z.record(z.unknown()); | ||
| 10 | |||
| 11 | function toRecord(value: unknown): Record<string, unknown> { | ||
| 12 | const parsed = recordSchema.safeParse(value); | ||
| 13 | return parsed.success ? parsed.data : {}; | ||
| 14 | } | ||
| 9 | 15 | ||
| 10 | export interface SerializedTool { | 16 | export interface SerializedTool { |
| 11 | path: string; | 17 | path: string; |
| @@ -124,21 +130,62 @@ const toolTypingSchema: z.ZodType<ToolTyping> = z.object({ · 112 unmodified lines | |||
| 124 | 130 | ||
| 125 | const toolCredentialSpecSchema: z.ZodType<ToolCredentialSpec> = z.object({ | 131 | const toolCredentialSpecSchema: z.ZodType<ToolCredentialSpec> = z.object({ |
| 126 | sourceKey: z.string(), | 132 | sourceKey: z.string(), |
| 127 | mode: z.enum(["static", "workspace", "actor"]), | 133 | mode: z.enum(["workspace", "actor"]), |
| 128 | authType: z.enum(["bearer", "apiKey", "basic"]), | 134 | authType: z.enum(["bearer", "apiKey", "basic"]), |
| 129 | headerName: z.string().optional(), | 135 | headerName: z.string().optional(), |
| 130 | staticSecretJson: z.record(z.unknown()).optional(), | 136 | staticSecretJson: z.record(z.unknown()).optional(), |
| 131 | }); | 137 | }); |
| 132 | 138 | ||
| 133 | const graphqlRawInputSchema = z.object({ | 139 | const graphqlObjectInputSchema = z.object({ |
| 134 | query: z.string(), | 140 | query: z.string().optional(), |
| 135 | variables: z.unknown().optional(), | 141 | variables: z.unknown().optional(), |
| 142 | }).catchall(z.unknown()); | ||
| 143 | |||
| 144 | const graphqlInvocationInputSchema = z.union([ | ||
| 145 | z.string(), | ||
| 146 | graphqlObjectInputSchema, | ||
| 147 | ]); | ||
| 148 | |||
| 149 | const invalidSerializedToolFallbackSchema = z.object({ | ||
| 150 | path: z.string().optional(), | ||
| 151 | source: z.string().optional(), | ||
| 136 | }); | 152 | }); |
| 137 | 153 | ||
| 138 | const graphqlFieldInputSchema = z.object({ | 154 | function normalizeGraphqlInvocationInput(input: unknown): { |
| 139 | query: z.string().optional(), | 155 | payload: Record<string, unknown>; |
| 140 | variables: z.unknown().optional(), | 156 | query: string; |
| 141 | }).passthrough(); | 157 | variables: unknown; |
| 158 | hasExplicitQuery: boolean; | ||
| 159 | } { | ||
| 160 | const parsedInput = graphqlInvocationInputSchema.safeParse(input); | ||
| 161 | if (!parsedInput.success) { | ||
| 162 | const payload = toRecord(input); | ||
| 163 | return { | ||
| 164 | payload, | ||
| 165 | query: "", | ||
| 166 | variables: payload.variables, | ||
| 167 | hasExplicitQuery: false, | ||
| 168 | }; | ||
| 169 | } | ||
| 170 | |||
| 171 | if (typeof parsedInput.data === "string") { | ||
| 172 | const query = parsedInput.data.trim(); | ||
| 173 | return { | ||
| 174 | payload: { query: parsedInput.data }, | ||
| 175 | query, | ||
| 176 | variables: undefined, | ||
| 177 | hasExplicitQuery: query.length > 0, | ||
| 178 | }; | ||
| 179 | } | ||
| 180 | |||
| 181 | const query = (parsedInput.data.query ?? "").trim(); | ||
| 182 | return { | ||
| 183 | payload: parsedInput.data, | ||
| 184 | query, | ||
| 185 | variables: parsedInput.data.variables, | ||
| 186 | hasExplicitQuery: query.length > 0, | ||
| 187 | }; | ||
| 188 | } | ||
| 142 | 189 | ||
| 143 | const serializedRunSpecSchema = z.union([ | 190 | const serializedRunSpecSchema = z.union([ |
| 144 | openApiRunSpecSchema, | 191 | openApiRunSpecSchema, |
| @@ -229,16 +276,16 @@ export function rehydrateTools( · 84 unmodified lines | |||
| 229 | return serialized.map((candidate, index) => { | 276 | return serialized.map((candidate, index) => { |
| 230 | const parsed = parseSerializedTool(candidate); | 277 | const parsed = parseSerializedTool(candidate); |
| 231 | if (parsed.isErr()) { | 278 | if (parsed.isErr()) { |
| 232 | const raw = asRecord(candidate); | 279 | const fallback = invalidSerializedToolFallbackSchema.safeParse(candidate); |
| 233 | const path = typeof raw.path === "string" && raw.path.trim().length > 0 | 280 | const path = fallback.success && fallback.data.path && fallback.data.path.trim().length > 0 |
| 234 | ? raw.path | 281 | ? fallback.data.path |
| 235 | : `invalid_serialized_tool_${index + 1}`; | 282 | : `invalid_serialized_tool_${index + 1}`; |
| 236 | 283 | ||
| 237 | return { | 284 | return { |
| 238 | path, | 285 | path, |
| 239 | description: "Invalid serialized tool definition", | 286 | description: "Invalid serialized tool definition", |
| 240 | approval: "required", | 287 | approval: "required", |
| 241 | source: typeof raw.source === "string" ? raw.source : undefined, | 288 | source: fallback.success ? fallback.data.source : undefined, |
| 242 | run: async () => { | 289 | run: async () => { |
| 243 | throw new Error(`Invalid serialized tool '${path}': ${parsed.error.message}`); | 290 | throw new Error(`Invalid serialized tool '${path}': ${parsed.error.message}`); |
| 244 | }, | 291 | }, |
| @@ -282,7 +329,7 @@ export function rehydrateTools( · 37 unmodified lines | |||
| 282 | return { | 329 | return { |
| 283 | ...base, | 330 | ...base, |
| 284 | run: async (input: unknown, context) => { | 331 | run: async (input: unknown, context) => { |
| 285 | const payload = asRecord(input); | 332 | const payload = toRecord(input); |
| 286 | return await executePostmanRequest(runSpec, payload, context.credential?.headers); | 333 | return await executePostmanRequest(runSpec, payload, context.credential?.headers); |
| 287 | }, | 334 | }, |
| 288 | }; | 335 | }; |
| @@ -305,7 +352,7 @@ export function rehydrateTools( · 16 unmodified lines | |||
| 305 | () => connectMcp(url, queryParams, transport, mergedHeaders), | 352 | () => connectMcp(url, queryParams, transport, mergedHeaders), |
| 306 | ); | 353 | ); |
| 307 | 354 | ||
| 308 | const payload = asRecord(input); | 355 | const payload = toRecord(input); |
| 309 | const result = await callMcpToolWithReconnect( | 356 | const result = await callMcpToolWithReconnect( |
| 310 | () => conn.client.callTool({ name: toolName, arguments: payload }), | 357 | () => conn.client.callTool({ name: toolName, arguments: payload }), |
| 311 | async () => { | 358 | async () => { |
| @@ -330,14 +377,17 @@ export function rehydrateTools( · 18 unmodified lines | |||
| 330 | return { | 377 | return { |
| 331 | ...base, | 378 | ...base, |
| 332 | run: async (input: unknown, context) => { | 379 | run: async (input: unknown, context) => { |
| 333 | const payload = asRecord(input); | 380 | const normalized = normalizeGraphqlInvocationInput(input); |
| 334 | const parsedInput = graphqlRawInputSchema.safeParse(payload); | 381 | if (!normalized.hasExplicitQuery) { |
| 335 | const query = parsedInput.success ? parsedInput.data.query : ""; | ||
| 336 | if (!query.trim()) { | ||
| 337 | throw new Error("GraphQL query string is required"); | 382 | throw new Error("GraphQL query string is required"); |
| 338 | } | 383 | } |
| 339 | const variables = parsedInput.success ? parsedInput.data.variables : undefined; | 384 | const response = await executeGraphqlRequest( |
| 340 | const response = await executeGraphqlRequest(endpoint, authHeaders, query, variables, context.credential?.headers); | 385 | endpoint, |
| 386 | authHeaders, | ||
| 387 | normalized.query, | ||
| 388 | normalized.variables, | ||
| 389 | context.credential?.headers, | ||
| 390 | ); | ||
| 341 | if (response.isErr()) { | 391 | if (response.isErr()) { |
| 342 | throw new Error(response.error.message); | 392 | throw new Error(response.error.message); |
| 343 | } | 393 | } |
| @@ -351,15 +401,12 @@ export function rehydrateTools( · 7 unmodified lines | |||
| 351 | return { | 401 | return { |
| 352 | ...base, | 402 | ...base, |
| 353 | run: async (input: unknown, context) => { | 403 | run: async (input: unknown, context) => { |
| 354 | const payload = asRecord(input); | 404 | const normalized = normalizeGraphqlInvocationInput(input); |
| 355 | const parsedInput = graphqlFieldInputSchema.safeParse(payload); | 405 | const query = normalized.hasExplicitQuery ? normalized.query : queryTemplate; |
| 356 | const normalizedInput = parsedInput.success ? parsedInput.data : {}; | 406 | |
| 357 | const hasExplicitQuery = typeof normalizedInput.query === "string" && normalizedInput.query.trim().length > 0; | 407 | let variables = normalized.variables; |
| 358 | const query = hasExplicitQuery ? normalizedInput.query : queryTemplate; | 408 | if (variables === undefined && !normalized.hasExplicitQuery) { |
| 359 | 409 | variables = normalizeGraphqlFieldVariables(argNames ?? [], normalized.payload); | |
| 360 | let variables = normalizedInput.variables; | ||
| 361 | if (variables === undefined && !hasExplicitQuery) { | ||
| 362 | variables = normalizeGraphqlFieldVariables(argNames ?? [], payload); | ||
| 363 | } | 410 | } |
| 364 | 411 | ||
| 365 | const envelopeResult = await executeGraphqlRequest( | 412 | const envelopeResult = await executeGraphqlRequest( |
| @@ -6,6 +6,11 @@ export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_ · 5 unmodified lines | |||
| 6 | export type ApprovalStatus = "pending" | "approved" | "denied"; | 6 | export type ApprovalStatus = "pending" | "approved" | "denied"; |
| 7 | export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied"; | 7 | export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied"; |
| 8 | export type PolicyDecision = "allow" | "require_approval" | "deny"; | 8 | export type PolicyDecision = "allow" | "require_approval" | "deny"; |
| 9 | export type PolicyScopeType = "organization" | "workspace"; | ||
| 10 | export type PolicyMatchType = "glob" | "exact"; | ||
| 11 | export type PolicyEffect = "allow" | "deny"; | ||
| 12 | export type PolicyApprovalMode = "inherit" | "auto" | "required"; | ||
| 13 | export type OwnerScopeType = "organization" | "workspace"; | ||
| 9 | export type CredentialScope = "workspace" | "actor"; | 14 | export type CredentialScope = "workspace" | "actor"; |
| 10 | export type CredentialProvider = "local-convex" | "workos-vault"; | 15 | export type CredentialProvider = "local-convex" | "workos-vault"; |
| 11 | export type ToolApprovalMode = "auto" | "required"; | 16 | export type ToolApprovalMode = "auto" | "required"; |
| @@ -87,20 +92,31 @@ export interface ToolCallRecord { · 75 unmodified lines | |||
| 87 | 92 | ||
| 88 | export interface AccessPolicyRecord { | 93 | export interface AccessPolicyRecord { |
| 89 | id: string; | 94 | id: string; |
| 90 | workspaceId: Id<"workspaces">; | 95 | scopeType?: PolicyScopeType; |
| 91 | actorId?: string; | 96 | organizationId?: Id<"organizations">; |
| 97 | workspaceId?: Id<"workspaces">; | ||
| 98 | targetActorId?: string; | ||
| 92 | clientId?: string; | 99 | clientId?: string; |
| 93 | toolPathPattern: string; | 100 | resourceType?: "tool_path"; |
| 94 | decision: PolicyDecision; | 101 | resourcePattern?: string; |
| 102 | matchType?: PolicyMatchType; | ||
| 103 | effect?: PolicyEffect; | ||
| 104 | approvalMode?: PolicyApprovalMode; | ||
| 95 | priority: number; | 105 | priority: number; |
| 96 | createdAt: number; | 106 | createdAt: number; |
| 97 | updatedAt: number; | 107 | updatedAt: number; |
| 108 | // Legacy aliases retained for callers that still expect v1 fields. | ||
| 109 | actorId?: string; | ||
| 110 | toolPathPattern: string; | ||
| 111 | decision: PolicyDecision; | ||
| 98 | } | 112 | } |
| 99 | 113 | ||
| 100 | export interface CredentialRecord { | 114 | export interface CredentialRecord { |
| 101 | id: string; | 115 | id: string; |
| 102 | bindingId?: string; | 116 | bindingId?: string; |
| 103 | workspaceId: Id<"workspaces">; | 117 | ownerScopeType?: OwnerScopeType; |
| 118 | organizationId?: Id<"organizations">; | ||
| 119 | workspaceId?: Id<"workspaces">; | ||
| 104 | sourceKey: string; | 120 | sourceKey: string; |
| 105 | scope: CredentialScope; | 121 | scope: CredentialScope; |
| 106 | actorId?: string; | 122 | actorId?: string; |
| @@ -114,10 +130,14 @@ export interface CredentialRecord { · 7 unmodified lines | |||
| 114 | 130 | ||
| 115 | export interface ToolSourceRecord { | 131 | export interface ToolSourceRecord { |
| 116 | id: string; | 132 | id: string; |
| 117 | workspaceId: Id<"workspaces">; | 133 | ownerScopeType?: OwnerScopeType; |
| 134 | organizationId?: Id<"organizations">; | ||
| 135 | workspaceId?: Id<"workspaces">; | ||
| 118 | name: string; | 136 | name: string; |
| 119 | type: ToolSourceType; | 137 | type: ToolSourceType; |
| 120 | config: Record<string, unknown>; | 138 | config: Record<string, unknown>; |
| 139 | specHash?: string; | ||
| 140 | authFingerprint?: string; | ||
| 121 | enabled: boolean; | 141 | enabled: boolean; |
| 122 | createdAt: number; | 142 | createdAt: number; |
| 123 | updatedAt: number; | 143 | updatedAt: number; |
| @@ -1,12 +1,18 @@ | |||
| 1 | /** | 1 | /** |
| 2 | * Safely cast an unknown value to a Record<string, unknown>. | 2 | * Runtime guard for plain object payloads. |
| 3 | * Returns an empty object for nullish, non-object, or array values. | ||
| 4 | */ | 3 | */ |
| 5 | export function asRecord(value: unknown): Record<string, unknown> { | 4 | export function isPlainObject(value: unknown): value is Record<string, unknown> { |
| 6 | if (value && typeof value === "object" && !Array.isArray(value)) { | 5 | if (!value || typeof value !== "object" || Array.isArray(value)) return false; |
| 7 | return value as Record<string, unknown>; | 6 | |
| 8 | } | 7 | const prototype = Object.getPrototypeOf(value); |
| 9 | return {}; | 8 | return prototype === Object.prototype || prototype === null; |
| 9 | } | ||
| 10 | |||
| 11 | /** | ||
| 12 | * Convert a runtime value into a plain object record, or return undefined. | ||
| 13 | */ | ||
| 14 | export function toPlainObject(value: unknown): Record<string, unknown> | undefined { | ||
| 15 | return isPlainObject(value) ? value : undefined; | ||
| 10 | } | 16 | } |
| 11 | 17 | ||
| 12 | /** | 18 | /** |
| @@ -5,6 +5,7 @@ · 4 unmodified lines | |||
| 5 | * round-trip with functional `run` methods. | 5 | * round-trip with functional `run` methods. |
| 6 | */ | 6 | */ |
| 7 | import { test, expect, describe } from "bun:test"; | 7 | import { test, expect, describe } from "bun:test"; |
| 8 | import { z } from "zod"; | ||
| 8 | import type { Id } from "../../../database/convex/_generated/dataModel"; | 9 | import type { Id } from "../../../database/convex/_generated/dataModel"; |
| 9 | import { | 10 | import { |
| 10 | prepareOpenApiSpec, | 11 | prepareOpenApiSpec, |
| @@ -20,6 +21,12 @@ import type { ToolDefinition } from "../types"; · 9 unmodified lines | |||
| 20 | 21 | ||
| 21 | const TEST_WORKSPACE_ID = "w" as Id<"workspaces">; | 22 | const TEST_WORKSPACE_ID = "w" as Id<"workspaces">; |
| 22 | 23 | ||
| 24 | const unknownRecordSchema = z.record(z.unknown()); | ||
| 25 | const graphqlBodySchema = z.object({ | ||
| 26 | query: z.string().optional(), | ||
| 27 | variables: z.unknown().optional(), | ||
| 28 | }); | ||
| 29 | |||
| 23 | function makeBaseTools(): Map<string, ToolDefinition> { | 30 | function makeBaseTools(): Map<string, ToolDefinition> { |
| 24 | return new Map([ | 31 | return new Map([ |
| 25 | [ | 32 | [ |
| @@ -36,7 +43,8 @@ function makeBaseTools(): Map<string, ToolDefinition> { · 10 unmodified lines | |||
| 36 | previewInputKeys: ["message"], | 43 | previewInputKeys: ["message"], |
| 37 | }, | 44 | }, |
| 38 | run: async (input: unknown) => { | 45 | run: async (input: unknown) => { |
| 39 | const payload = input as Record<string, unknown>; | 46 | const parsedPayload = unknownRecordSchema.safeParse(input); |
| 47 | const payload = parsedPayload.success ? parsedPayload.data : {}; | ||
| 40 | return payload.message; | 48 | return payload.message; |
| 41 | }, | 49 | }, |
| 42 | }, | 50 | }, |
| @@ -338,7 +346,8 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 295 unmodified lines | |||
| 338 | const originalFetch = globalThis.fetch; | 346 | const originalFetch = globalThis.fetch; |
| 339 | const calls: Array<{ query: string; variables: unknown }> = []; | 347 | const calls: Array<{ query: string; variables: unknown }> = []; |
| 340 | globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { | 348 | globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { |
| 341 | const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string; variables?: unknown }; | 349 | const parsedBody = graphqlBodySchema.safeParse(JSON.parse(String(init?.body ?? "{}"))); |
| 350 | const body = parsedBody.success ? parsedBody.data : {}; | ||
| 342 | calls.push({ query: body.query ?? "", variables: body.variables }); | 351 | calls.push({ query: body.query ?? "", variables: body.variables }); |
| 343 | 352 | ||
| 344 | if ((body.query ?? "").includes("teams")) { | 353 | if ((body.query ?? "").includes("teams")) { |
| @@ -359,14 +368,19 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 14 unmodified lines | |||
| 359 | { query: "query viewer { viewer { id } }" }, | 368 | { query: "query viewer { viewer { id } }" }, |
| 360 | { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true }, | 369 | { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true }, |
| 361 | ); | 370 | ); |
| 371 | const rawStringResult = await rawTool!.run( | ||
| 372 | "query viewer { viewer { id } }", | ||
| 373 | { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true }, | ||
| 374 | ); | ||
| 362 | const teamsResult = await teamsTool!.run( | 375 | const teamsResult = await teamsTool!.run( |
| 363 | {}, | 376 | {}, |
| 364 | { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true }, | 377 | { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true }, |
| 365 | ); | 378 | ); |
| 366 | 379 | ||
| 367 | expect(rawResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] }); | 380 | expect(rawResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] }); |
| 381 | expect(rawStringResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] }); | ||
| 368 | expect(teamsResult).toEqual({ data: [{ id: "team_1", name: "Core" }], errors: [] }); | 382 | expect(teamsResult).toEqual({ data: [{ id: "team_1", name: "Core" }], errors: [] }); |
| 369 | expect(calls.length).toBe(2); | 383 | expect(calls.length).toBe(3); |
| 370 | } finally { | 384 | } finally { |
| 371 | globalThis.fetch = originalFetch; | 385 | globalThis.fetch = originalFetch; |
| 372 | } | 386 | } |
| @@ -88,9 +88,10 @@ export declare const api: { · 87 unmodified lines | |||
| 88 | { | 88 | { |
| 89 | actorId?: string; | 89 | actorId?: string; |
| 90 | id?: string; | 90 | id?: string; |
| 91 | ownerScopeType?: "organization" | "workspace"; | ||
| 91 | provider?: "local-convex" | "workos-vault"; | 92 | provider?: "local-convex" | "workos-vault"; |
| 92 | scope: "workspace" | "actor"; | 93 | scope: "workspace" | "actor"; |
| 93 | secretJson: any; | 94 | secretJson: Record<string, any>; |
| 94 | sessionId?: string; | 95 | sessionId?: string; |
| 95 | sourceKey: string; | 96 | sourceKey: string; |
| 96 | workspaceId: Id<"workspaces">; | 97 | workspaceId: Id<"workspaces">; |
| @@ -106,7 +107,7 @@ export declare const api: { · 9 unmodified lines | |||
| 106 | actorId?: string; | 107 | actorId?: string; |
| 107 | clientId?: string; | 108 | clientId?: string; |
| 108 | code: string; | 109 | code: string; |
| 109 | metadata?: any; | 110 | metadata?: Record<string, any>; |
| 110 | runtimeId?: string; | 111 | runtimeId?: string; |
| 111 | sessionId?: string; | 112 | sessionId?: string; |
| 112 | timeoutMs?: number; | 113 | timeoutMs?: number; |
| @@ -268,7 +269,7 @@ export declare const api: { · 155 unmodified lines | |||
| 268 | "public", | 269 | "public", |
| 269 | { | 270 | { |
| 270 | callId: string; | 271 | callId: string; |
| 271 | input?: any; | 272 | input?: Record<string, any>; |
| 272 | internalSecret: string; | 273 | internalSecret: string; |
| 273 | runId: string; | 274 | runId: string; |
| 274 | toolPath: string; | 275 | toolPath: string; |
| @@ -336,10 +337,14 @@ export declare const api: { · 61 unmodified lines | |||
| 336 | "public", | 337 | "public", |
| 337 | { | 338 | { |
| 338 | actorId?: string; | 339 | actorId?: string; |
| 340 | approvalMode?: "inherit" | "auto" | "required"; | ||
| 339 | clientId?: string; | 341 | clientId?: string; |
| 340 | decision: "allow" | "require_approval" | "deny"; | 342 | decision?: "allow" | "require_approval" | "deny"; |
| 343 | effect?: "allow" | "deny"; | ||
| 341 | id?: string; | 344 | id?: string; |
| 345 | matchType?: "glob" | "exact"; | ||
| 342 | priority?: number; | 346 | priority?: number; |
| 347 | scopeType?: "organization" | "workspace"; | ||
| 343 | sessionId?: string; | 348 | sessionId?: string; |
| 344 | toolPathPattern: string; | 349 | toolPathPattern: string; |
| 345 | workspaceId: Id<"workspaces">; | 350 | workspaceId: Id<"workspaces">; |
| @@ -352,10 +357,11 @@ export declare const api: { · 6 unmodified lines | |||
| 352 | { | 357 | { |
| 353 | actorId?: string; | 358 | actorId?: string; |
| 354 | id?: string; | 359 | id?: string; |
| 355 | overridesJson?: any; | 360 | overridesJson?: Record<string, any>; |
| 361 | ownerScopeType?: "organization" | "workspace"; | ||
| 356 | provider?: "local-convex" | "workos-vault"; | 362 | provider?: "local-convex" | "workos-vault"; |
| 357 | scope: "workspace" | "actor"; | 363 | scope: "workspace" | "actor"; |
| 358 | secretJson: any; | 364 | secretJson: Record<string, any>; |
| 359 | sessionId?: string; | 365 | sessionId?: string; |
| 360 | sourceKey: string; | 366 | sourceKey: string; |
| 361 | workspaceId: Id<"workspaces">; | 367 | workspaceId: Id<"workspaces">; |
| @@ -366,10 +372,11 @@ export declare const api: { · 4 unmodified lines | |||
| 366 | "mutation", | 372 | "mutation", |
| 367 | "public", | 373 | "public", |
| 368 | { | 374 | { |
| 369 | config: any; | 375 | config: Record<string, any>; |
| 370 | enabled?: boolean; | 376 | enabled?: boolean; |
| 371 | id?: string; | 377 | id?: string; |
| 372 | name: string; | 378 | name: string; |
| 379 | ownerScopeType?: "organization" | "workspace"; | ||
| 373 | sessionId?: string; | 380 | sessionId?: string; |
| 374 | type: "mcp" | "openapi" | "graphql"; | 381 | type: "mcp" | "openapi" | "graphql"; |
| 375 | workspaceId: Id<"workspaces">; | 382 | workspaceId: Id<"workspaces">; |
| @@ -486,7 +493,12 @@ export declare const internal: { · 110 unmodified lines | |||
| 486 | createApproval: FunctionReference< | 493 | createApproval: FunctionReference< |
| 487 | "mutation", | 494 | "mutation", |
| 488 | "internal", | 495 | "internal", |
| 489 | { id: string; input?: any; taskId: string; toolPath: string }, | 496 | { |
| 497 | id: string; | ||
| 498 | input?: Record<string, any>; | ||
| 499 | taskId: string; | ||
| 500 | toolPath: string; | ||
| 501 | }, | ||
| 490 | any | 502 | any |
| 491 | >; | 503 | >; |
| 492 | getApproval: FunctionReference< | 504 | getApproval: FunctionReference< |
| @@ -537,7 +549,12 @@ export declare const internal: { · 44 unmodified lines | |||
| 537 | createApproval: FunctionReference< | 549 | createApproval: FunctionReference< |
| 538 | "mutation", | 550 | "mutation", |
| 539 | "internal", | 551 | "internal", |
| 540 | { id: string; input?: any; taskId: string; toolPath: string }, | 552 | { |
| 553 | id: string; | ||
| 554 | input?: Record<string, any>; | ||
| 555 | taskId: string; | ||
| 556 | toolPath: string; | ||
| 557 | }, | ||
| 541 | any | 558 | any |
| 542 | >; | 559 | >; |
| 543 | createTask: FunctionReference< | 560 | createTask: FunctionReference< |
| @@ -548,7 +565,7 @@ export declare const internal: { · 4 unmodified lines | |||
| 548 | clientId?: string; | 565 | clientId?: string; |
| 549 | code: string; | 566 | code: string; |
| 550 | id: string; | 567 | id: string; |
| 551 | metadata?: any; | 568 | metadata?: Record<string, any>; |
| 552 | runtimeId: string; | 569 | runtimeId: string; |
| 553 | timeoutMs?: number; | 570 | timeoutMs?: number; |
| 554 | workspaceId: Id<"workspaces">; | 571 | workspaceId: Id<"workspaces">; |
| @@ -558,7 +575,12 @@ export declare const internal: { · 3 unmodified lines | |||
| 558 | createTaskEvent: FunctionReference< | 575 | createTaskEvent: FunctionReference< |
| 559 | "mutation", | 576 | "mutation", |
| 560 | "internal", | 577 | "internal", |
| 561 | { eventName: string; payload: any; taskId: string; type: string }, | 578 | { |
| 579 | eventName: string; | ||
| 580 | payload: Record<string, any>; | ||
| 581 | taskId: string; | ||
| 582 | type: string; | ||
| 583 | }, | ||
| 562 | any | 584 | any |
| 563 | >; | 585 | >; |
| 564 | credentials: { | 586 | credentials: { |
| @@ -586,10 +608,11 @@ export declare const internal: { · 21 unmodified lines | |||
| 586 | { | 608 | { |
| 587 | actorId?: string; | 609 | actorId?: string; |
| 588 | id?: string; | 610 | id?: string; |
| 589 | overridesJson?: any; | 611 | overridesJson?: Record<string, any>; |
| 612 | ownerScopeType?: "organization" | "workspace"; | ||
| 590 | provider?: "local-convex" | "workos-vault"; | 613 | provider?: "local-convex" | "workos-vault"; |
| 591 | scope: "workspace" | "actor"; | 614 | scope: "workspace" | "actor"; |
| 592 | secretJson: any; | 615 | secretJson: Record<string, any>; |
| 593 | sourceKey: string; | 616 | sourceKey: string; |
| 594 | workspaceId: Id<"workspaces">; | 617 | workspaceId: Id<"workspaces">; |
| 595 | }, | 618 | }, |
| @@ -733,11 +756,17 @@ export declare const internal: { · 137 unmodified lines | |||
| 733 | "internal", | 756 | "internal", |
| 734 | { | 757 | { |
| 735 | actorId?: string; | 758 | actorId?: string; |
| 759 | approvalMode?: "inherit" | "auto" | "required"; | ||
| 736 | clientId?: string; | 760 | clientId?: string; |
| 737 | decision: "allow" | "require_approval" | "deny"; | 761 | decision?: "allow" | "require_approval" | "deny"; |
| 762 | effect?: "allow" | "deny"; | ||
| 738 | id?: string; | 763 | id?: string; |
| 764 | matchType?: "glob" | "exact"; | ||
| 739 | priority?: number; | 765 | priority?: number; |
| 740 | toolPathPattern: string; | 766 | resourcePattern?: string; |
| 767 | scopeType?: "organization" | "workspace"; | ||
| 768 | targetActorId?: string; | ||
| 769 | toolPathPattern?: string; | ||
| 741 | workspaceId: Id<"workspaces">; | 770 | workspaceId: Id<"workspaces">; |
| 742 | }, | 771 | }, |
| 743 | any | 772 | any |
| @@ -775,7 +804,12 @@ export declare const internal: { · 31 unmodified lines | |||
| 775 | createTaskEvent: FunctionReference< | 804 | createTaskEvent: FunctionReference< |
| 776 | "mutation", | 805 | "mutation", |
| 777 | "internal", | 806 | "internal", |
| 778 | { eventName: string; payload: any; taskId: string; type: string }, | 807 | { |
| 808 | eventName: string; | ||
| 809 | payload: Record<string, any>; | ||
| 810 | taskId: string; | ||
| 811 | type: string; | ||
| 812 | }, | ||
| 779 | any | 813 | any |
| 780 | >; | 814 | >; |
| 781 | listTaskEvents: FunctionReference< | 815 | listTaskEvents: FunctionReference< |
| @@ -794,7 +828,7 @@ export declare const internal: { · 12 unmodified lines | |||
| 794 | clientId?: string; | 828 | clientId?: string; |
| 795 | code: string; | 829 | code: string; |
| 796 | id: string; | 830 | id: string; |
| 797 | metadata?: any; | 831 | metadata?: Record<string, any>; |
| 798 | runtimeId: string; | 832 | runtimeId: string; |
| 799 | timeoutMs?: number; | 833 | timeoutMs?: number; |
| 800 | workspaceId: Id<"workspaces">; | 834 | workspaceId: Id<"workspaces">; |
| @@ -897,10 +931,11 @@ export declare const internal: { · 96 unmodified lines | |||
| 897 | "mutation", | 931 | "mutation", |
| 898 | "internal", | 932 | "internal", |
| 899 | { | 933 | { |
| 900 | config: any; | 934 | config: Record<string, any>; |
| 901 | enabled?: boolean; | 935 | enabled?: boolean; |
| 902 | id?: string; | 936 | id?: string; |
| 903 | name: string; | 937 | name: string; |
| 938 | ownerScopeType?: "organization" | "workspace"; | ||
| 904 | type: "mcp" | "openapi" | "graphql"; | 939 | type: "mcp" | "openapi" | "graphql"; |
| 905 | workspaceId: Id<"workspaces">; | 940 | workspaceId: Id<"workspaces">; |
| 906 | }, | 941 | }, |
| @@ -912,11 +947,17 @@ export declare const internal: { · 5 unmodified lines | |||
| 912 | "internal", | 947 | "internal", |
| 913 | { | 948 | { |
| 914 | actorId?: string; | 949 | actorId?: string; |
| 950 | approvalMode?: "inherit" | "auto" | "required"; | ||
| 915 | clientId?: string; | 951 | clientId?: string; |
| 916 | decision: "allow" | "require_approval" | "deny"; | 952 | decision?: "allow" | "require_approval" | "deny"; |
| 953 | effect?: "allow" | "deny"; | ||
| 917 | id?: string; | 954 | id?: string; |
| 955 | matchType?: "glob" | "exact"; | ||
| 918 | priority?: number; | 956 | priority?: number; |
| 919 | toolPathPattern: string; | 957 | resourcePattern?: string; |
| 958 | scopeType?: "organization" | "workspace"; | ||
| 959 | targetActorId?: string; | ||
| 960 | toolPathPattern?: string; | ||
| 920 | workspaceId: Id<"workspaces">; | 961 | workspaceId: Id<"workspaces">; |
| 921 | }, | 962 | }, |
| 922 | any | 963 | any |
| @@ -927,10 +968,11 @@ export declare const internal: { · 4 unmodified lines | |||
| 927 | { | 968 | { |
| 928 | actorId?: string; | 969 | actorId?: string; |
| 929 | id?: string; | 970 | id?: string; |
| 930 | overridesJson?: any; | 971 | overridesJson?: Record<string, any>; |
| 972 | ownerScopeType?: "organization" | "workspace"; | ||
| 931 | provider?: "local-convex" | "workos-vault"; | 973 | provider?: "local-convex" | "workos-vault"; |
| 932 | scope: "workspace" | "actor"; | 974 | scope: "workspace" | "actor"; |
| 933 | secretJson: any; | 975 | secretJson: Record<string, any>; |
| 934 | sourceKey: string; | 976 | sourceKey: string; |
| 935 | workspaceId: Id<"workspaces">; | 977 | workspaceId: Id<"workspaces">; |
| 936 | }, | 978 | }, |
| @@ -951,10 +993,11 @@ export declare const internal: { · 14 unmodified lines | |||
| 951 | "mutation", | 993 | "mutation", |
| 952 | "internal", | 994 | "internal", |
| 953 | { | 995 | { |
| 954 | config: any; | 996 | config: Record<string, any>; |
| 955 | enabled?: boolean; | 997 | enabled?: boolean; |
| 956 | id?: string; | 998 | id?: string; |
| 957 | name: string; | 999 | name: string; |
| 1000 | ownerScopeType?: "organization" | "workspace"; | ||
| 958 | type: "mcp" | "openapi" | "graphql"; | 1001 | type: "mcp" | "openapi" | "graphql"; |
| 959 | workspaceId: Id<"workspaces">; | 1002 | workspaceId: Id<"workspaces">; |
| 960 | }, | 1003 | }, |
| @@ -981,7 +1024,7 @@ export declare const internal: { · 20 unmodified lines | |||
| 981 | actorId: string; | 1024 | actorId: string; |
| 982 | clientId?: string; | 1025 | clientId?: string; |
| 983 | code: string; | 1026 | code: string; |
| 984 | metadata?: any; | 1027 | metadata?: Record<string, any>; |
| 985 | runtimeId?: string; | 1028 | runtimeId?: string; |
| 986 | scheduleAfterCreate?: boolean; | 1029 | scheduleAfterCreate?: boolean; |
| 987 | timeoutMs?: number; | 1030 | timeoutMs?: number; |
| @@ -1006,7 +1049,12 @@ export declare const internal: { · 18 unmodified lines | |||
| 1006 | handleExternalToolCall: FunctionReference< | 1049 | handleExternalToolCall: FunctionReference< |
| 1007 | "action", | 1050 | "action", |
| 1008 | "internal", | 1051 | "internal", |
| 1009 | { callId: string; input?: any; runId: string; toolPath: string }, | 1052 | { |
| 1053 | callId: string; | ||
| 1054 | input?: Record<string, any>; | ||
| 1055 | runId: string; | ||
| 1056 | toolPath: string; | ||
| 1057 | }, | ||
| 1010 | any | 1058 | any |
| 1011 | >; | 1059 | >; |
| 1012 | listToolsInternal: FunctionReference< | 1060 | listToolsInternal: FunctionReference< |
| @@ -1258,6 +1306,12 @@ export declare const internal: { · 245 unmodified lines | |||
| 1258 | }, | 1306 | }, |
| 1259 | any | 1307 | any |
| 1260 | >; | 1308 | >; |
| 1309 | pruneBuilds: FunctionReference< | ||
| 1310 | "mutation", | ||
| 1311 | "internal", | ||
| 1312 | { maxRetainedBuilds?: number; workspaceId: Id<"workspaces"> }, | ||
| 1313 | any | ||
| 1314 | >; | ||
| 1261 | putNamespacesBatch: FunctionReference< | 1315 | putNamespacesBatch: FunctionReference< |
| 1262 | "mutation", | 1316 | "mutation", |
| 1263 | "internal", | 1317 | "internal", |
| @@ -8,12 +8,14 @@ import { jsonObjectValidator } from "../src/database/validators"; · 7 unmodified lines | |||
| 8 | 8 | ||
| 9 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | 9 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); |
| 10 | const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault")); | 10 | const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault")); |
| 11 | const ownerScopeTypeValidator = v.union(v.literal("organization"), v.literal("workspace")); | ||
| 11 | 12 | ||
| 12 | export const upsertCredential = action({ | 13 | export const upsertCredential = action({ |
| 13 | args: { | 14 | args: { |
| 14 | id: v.optional(v.string()), | 15 | id: v.optional(v.string()), |
| 15 | workspaceId: v.id("workspaces"), | 16 | workspaceId: v.id("workspaces"), |
| 16 | sessionId: v.optional(v.string()), | 17 | sessionId: v.optional(v.string()), |
| 18 | ownerScopeType: v.optional(ownerScopeTypeValidator), | ||
| 17 | sourceKey: v.string(), | 19 | sourceKey: v.string(), |
| 18 | scope: credentialScopeValidator, | 20 | scope: credentialScopeValidator, |
| 19 | actorId: v.optional(v.string()), | 21 | actorId: v.optional(v.string()), |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { v } from "convex/values"; | 1 | import { v } from "convex/values"; |
| 2 | import { z } from "zod"; | ||
| 2 | import { internalMutation, internalQuery } from "../_generated/server"; | 3 | import { internalMutation, internalQuery } from "../_generated/server"; |
| 3 | import { mapCredential } from "../../src/database/mappers"; | 4 | import { mapCredential } from "../../src/database/mappers"; |
| 4 | import { computeBoundAuthFingerprint } from "../../src/database/readers"; | 5 | import { computeBoundAuthFingerprint } from "../../src/database/readers"; |
| @@ -6,8 +7,15 @@ import { · 1 unmodified line | |||
| 6 | credentialProviderValidator, | 7 | credentialProviderValidator, |
| 7 | credentialScopeValidator, | 8 | credentialScopeValidator, |
| 8 | jsonObjectValidator, | 9 | jsonObjectValidator, |
| 10 | ownerScopeTypeValidator, | ||
| 9 | } from "../../src/database/validators"; | 11 | } from "../../src/database/validators"; |
| 10 | import { asRecord } from "../../src/lib/object"; | 12 | |
| 13 | const recordSchema = z.record(z.unknown()); | ||
| 14 | |||
| 15 | function toRecordValue(value: unknown): Record<string, unknown> { | ||
| 16 | const parsed = recordSchema.safeParse(value); | ||
| 17 | return parsed.success ? parsed.data : {}; | ||
| 18 | } | ||
| 11 | 19 | ||
| 12 | function scopeKeyForCredential(scope: "workspace" | "actor", actorId?: string): string { | 20 | function scopeKeyForCredential(scope: "workspace" | "actor", actorId?: string): string { |
| 13 | if (scope === "workspace") { | 21 | if (scope === "workspace") { |
| @@ -26,6 +34,7 @@ export const upsertCredential = internalMutation({ · 12 unmodified lines | |||
| 26 | args: { | 34 | args: { |
| 27 | id: v.optional(v.string()), | 35 | id: v.optional(v.string()), |
| 28 | workspaceId: v.id("workspaces"), | 36 | workspaceId: v.id("workspaces"), |
| 37 | ownerScopeType: v.optional(ownerScopeTypeValidator), | ||
| 29 | sourceKey: v.string(), | 38 | sourceKey: v.string(), |
| 30 | scope: credentialScopeValidator, | 39 | scope: credentialScopeValidator, |
| 31 | actorId: v.optional(v.string()), | 40 | actorId: v.optional(v.string()), |
| @@ -35,20 +44,39 @@ export const upsertCredential = internalMutation({ · 3 unmodified lines | |||
| 35 | }, | 44 | }, |
| 36 | handler: async (ctx, args) => { | 45 | handler: async (ctx, args) => { |
| 37 | const now = Date.now(); | 46 | const now = Date.now(); |
| 47 | const ownerScopeType = args.ownerScopeType ?? "workspace"; | ||
| 48 | const workspace = await ctx.db.get(args.workspaceId); | ||
| 49 | if (!workspace) { | ||
| 50 | throw new Error(`Workspace not found: ${args.workspaceId}`); | ||
| 51 | } | ||
| 52 | const organizationId = workspace.organizationId; | ||
| 53 | const ownerWorkspaceId = ownerScopeType === "workspace" ? args.workspaceId : undefined; | ||
| 54 | |||
| 38 | const actorId = args.scope === "actor" ? args.actorId?.trim() : undefined; | 55 | const actorId = args.scope === "actor" ? args.actorId?.trim() : undefined; |
| 39 | const scopeKey = scopeKeyForCredential(args.scope, actorId); | 56 | const scopeKey = scopeKeyForCredential(args.scope, actorId); |
| 40 | const submittedSecret = asRecord(args.secretJson); | 57 | const submittedSecret = toRecordValue(args.secretJson); |
| 41 | const hasSubmittedSecret = Object.keys(submittedSecret).length > 0; | 58 | const hasSubmittedSecret = Object.keys(submittedSecret).length > 0; |
| 42 | 59 | ||
| 43 | const existing = await ctx.db | 60 | const existing = ownerScopeType === "workspace" |
| 44 | .query("sourceCredentials") | 61 | ? await ctx.db |
| 45 | .withIndex("by_workspace_source_scope_key", (q) => | 62 | .query("sourceCredentials") |
| 46 | q | 63 | .withIndex("by_workspace_source_scope_key", (q) => |
| 47 | .eq("workspaceId", args.workspaceId) | 64 | q |
| 48 | .eq("sourceKey", args.sourceKey) | 65 | .eq("workspaceId", args.workspaceId) |
| 49 | .eq("scopeKey", scopeKey), | 66 | .eq("sourceKey", args.sourceKey) |
| 50 | ) | 67 | .eq("scopeKey", scopeKey), |
| 51 | .unique(); | 68 | ) |
| 69 | .unique() | ||
| 70 | : await ctx.db | ||
| 71 | .query("sourceCredentials") | ||
| 72 | .withIndex("by_organization_owner_source_scope_key", (q) => | ||
| 73 | q | ||
| 74 | .eq("organizationId", organizationId) | ||
| 75 | .eq("ownerScopeType", "organization") | ||
| 76 | .eq("sourceKey", args.sourceKey) | ||
| 77 | .eq("scopeKey", scopeKey), | ||
| 78 | ) | ||
| 79 | .unique(); | ||
| 52 | 80 | ||
| 53 | let requestedId = args.id?.trim() || ""; | 81 | let requestedId = args.id?.trim() || ""; |
| 54 | if (requestedId.startsWith("bind_")) { | 82 | if (requestedId.startsWith("bind_")) { |
| @@ -56,31 +84,42 @@ export const upsertCredential = internalMutation({ · 1 unmodified line | |||
| 56 | .query("sourceCredentials") | 84 | .query("sourceCredentials") |
| 57 | .withIndex("by_binding_id", (q) => q.eq("bindingId", requestedId)) | 85 | .withIndex("by_binding_id", (q) => q.eq("bindingId", requestedId)) |
| 58 | .unique(); | 86 | .unique(); |
| 59 | if (binding && binding.workspaceId === args.workspaceId) { | 87 | const sameOwner = binding |
| 88 | && binding.organizationId === organizationId | ||
| 89 | && binding.ownerScopeType === ownerScopeType | ||
| 90 | && (ownerScopeType === "organization" || binding.workspaceId === args.workspaceId); | ||
| 91 | if (sameOwner) { | ||
| 60 | requestedId = binding.credentialId; | 92 | requestedId = binding.credentialId; |
| 61 | } | 93 | } |
| 62 | } | 94 | } |
| 63 | 95 | ||
| 64 | const connectionId = requestedId || existing?.credentialId || `conn_${crypto.randomUUID()}`; | 96 | const connectionId = requestedId || existing?.credentialId || `conn_${crypto.randomUUID()}`; |
| 65 | 97 | ||
| 66 | const linkedRows = await ctx.db | 98 | const linkedRows = ownerScopeType === "workspace" |
| 67 | .query("sourceCredentials") | 99 | ? await ctx.db |
| 68 | .withIndex("by_workspace_credential", (q) => | 100 | .query("sourceCredentials") |
| 69 | q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId), | 101 | .withIndex("by_workspace_credential", (q) => |
| 70 | ) | 102 | q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId), |
| 71 | .collect(); | 103 | ) |
| 104 | .collect() | ||
| 105 | : await ctx.db | ||
| 106 | .query("sourceCredentials") | ||
| 107 | .withIndex("by_organization_owner_credential", (q) => | ||
| 108 | q.eq("organizationId", organizationId).eq("ownerScopeType", "organization").eq("credentialId", connectionId), | ||
| 109 | ) | ||
| 110 | .collect(); | ||
| 72 | const exemplar = linkedRows[0] ?? existing ?? null; | 111 | const exemplar = linkedRows[0] ?? existing ?? null; |
| 73 | 112 | ||
| 74 | const provider = args.provider ?? exemplar?.provider ?? "local-convex"; | 113 | const provider = args.provider ?? exemplar?.provider ?? "local-convex"; |
| 75 | const fallbackSecret = asRecord(exemplar?.secretJson); | 114 | const fallbackSecret = toRecordValue(exemplar?.secretJson); |
| 76 | const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret; | 115 | const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret; |
| 77 | if (Object.keys(finalSecret).length === 0) { | 116 | if (Object.keys(finalSecret).length === 0) { |
| 78 | throw new Error("Credential values are required"); | 117 | throw new Error("Credential values are required"); |
| 79 | } | 118 | } |
| 80 | 119 | ||
| 81 | const overridesJson = args.overridesJson === undefined | 120 | const overridesJson = args.overridesJson === undefined |
| 82 | ? asRecord(existing?.overridesJson) | 121 | ? toRecordValue(existing?.overridesJson) |
| 83 | : asRecord(args.overridesJson); | 122 | : toRecordValue(args.overridesJson); |
| 84 | 123 | ||
| 85 | const boundAuthFingerprint = await computeBoundAuthFingerprint( | 124 | const boundAuthFingerprint = await computeBoundAuthFingerprint( |
| 86 | ctx, | 125 | ctx, |
| @@ -100,6 +139,9 @@ export const upsertCredential = internalMutation({ · 13 unmodified lines | |||
| 100 | 139 | ||
| 101 | if (existing) { | 140 | if (existing) { |
| 102 | await ctx.db.patch(existing._id, { | 141 | await ctx.db.patch(existing._id, { |
| 142 | ownerScopeType, | ||
| 143 | organizationId, | ||
| 144 | workspaceId: ownerWorkspaceId, | ||
| 103 | credentialId: connectionId, | 145 | credentialId: connectionId, |
| 104 | provider, | 146 | provider, |
| 105 | secretJson: finalSecret, | 147 | secretJson: finalSecret, |
| @@ -113,7 +155,9 @@ export const upsertCredential = internalMutation({ · 7 unmodified lines | |||
| 113 | await ctx.db.insert("sourceCredentials", { | 155 | await ctx.db.insert("sourceCredentials", { |
| 114 | bindingId: `bind_${crypto.randomUUID()}`, | 156 | bindingId: `bind_${crypto.randomUUID()}`, |
| 115 | credentialId: connectionId, | 157 | credentialId: connectionId, |
| 116 | workspaceId: args.workspaceId, | 158 | ownerScopeType, |
| 159 | organizationId, | ||
| 160 | workspaceId: ownerWorkspaceId, | ||
| 117 | sourceKey: args.sourceKey, | 161 | sourceKey: args.sourceKey, |
| 118 | scope: args.scope, | 162 | scope: args.scope, |
| 119 | scopeKey, | 163 | scopeKey, |
| @@ -127,15 +171,26 @@ export const upsertCredential = internalMutation({ · 7 unmodified lines | |||
| 127 | }); | 171 | }); |
| 128 | } | 172 | } |
| 129 | 173 | ||
| 130 | const updated = await ctx.db | 174 | const updated = ownerScopeType === "workspace" |
| 131 | .query("sourceCredentials") | 175 | ? await ctx.db |
| 132 | .withIndex("by_workspace_source_scope_key", (q) => | 176 | .query("sourceCredentials") |
| 133 | q | 177 | .withIndex("by_workspace_source_scope_key", (q) => |
| 134 | .eq("workspaceId", args.workspaceId) | 178 | q |
| 135 | .eq("sourceKey", args.sourceKey) | 179 | .eq("workspaceId", args.workspaceId) |
| 136 | .eq("scopeKey", scopeKey), | 180 | .eq("sourceKey", args.sourceKey) |
| 137 | ) | 181 | .eq("scopeKey", scopeKey), |
| 138 | .unique(); | 182 | ) |
| 183 | .unique() | ||
| 184 | : await ctx.db | ||
| 185 | .query("sourceCredentials") | ||
| 186 | .withIndex("by_organization_owner_source_scope_key", (q) => | ||
| 187 | q | ||
| 188 | .eq("organizationId", organizationId) | ||
| 189 | .eq("ownerScopeType", "organization") | ||
| 190 | .eq("sourceKey", args.sourceKey) | ||
| 191 | .eq("scopeKey", scopeKey), | ||
| 192 | ) | ||
| 193 | .unique(); | ||
| 139 | 194 | ||
| 140 | if (!updated) { | 195 | if (!updated) { |
| 141 | throw new Error("Failed to read upserted credential"); | 196 | throw new Error("Failed to read upserted credential"); |
| @@ -148,11 +203,30 @@ export const upsertCredential = internalMutation({ · 6 unmodified lines | |||
| 148 | export const listCredentials = internalQuery({ | 203 | export const listCredentials = internalQuery({ |
| 149 | args: { workspaceId: v.id("workspaces") }, | 204 | args: { workspaceId: v.id("workspaces") }, |
| 150 | handler: async (ctx, args) => { | 205 | handler: async (ctx, args) => { |
| 151 | const docs = await ctx.db | 206 | const workspace = await ctx.db.get(args.workspaceId); |
| 152 | .query("sourceCredentials") | 207 | if (!workspace) { |
| 153 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | 208 | return []; |
| 154 | .order("desc") | 209 | } |
| 155 | .collect(); | 210 | |
| 211 | const [workspaceDocs, organizationDocs] = await Promise.all([ | ||
| 212 | ctx.db | ||
| 213 | .query("sourceCredentials") | ||
| 214 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 215 | .order("desc") | ||
| 216 | .collect(), | ||
| 217 | ctx.db | ||
| 218 | .query("sourceCredentials") | ||
| 219 | .withIndex("by_organization_owner_created", (q) => | ||
| 220 | q.eq("organizationId", workspace.organizationId).eq("ownerScopeType", "organization"), | ||
| 221 | ) | ||
| 222 | .order("desc") | ||
| 223 | .collect(), | ||
| 224 | ]); | ||
| 225 | |||
| 226 | const docs = [...workspaceDocs, ...organizationDocs] | ||
| 227 | .filter((doc, index, entries) => entries.findIndex((candidate) => candidate.bindingId === doc.bindingId) === index) | ||
| 228 | .sort((a, b) => b.createdAt - a.createdAt); | ||
| 229 | |||
| 156 | return docs.map(mapCredential); | 230 | return docs.map(mapCredential); |
| 157 | }, | 231 | }, |
| 158 | }); | 232 | }); |
| @@ -181,35 +255,62 @@ export const resolveCredential = internalQuery({ · 22 unmodified lines | |||
| 181 | actorId: v.optional(v.string()), | 255 | actorId: v.optional(v.string()), |
| 182 | }, | 256 | }, |
| 183 | handler: async (ctx, args) => { | 257 | handler: async (ctx, args) => { |
| 184 | if (args.scope === "actor") { | 258 | const workspace = await ctx.db.get(args.workspaceId); |
| 185 | const actorId = args.actorId?.trim() || ""; | 259 | if (!workspace) { |
| 186 | if (!actorId) { | 260 | return null; |
| 187 | return null; | 261 | } |
| 188 | } | 262 | |
| 263 | const workspaceScopeKey = scopeKeyForCredential("workspace"); | ||
| 264 | const actorId = args.actorId?.trim() || ""; | ||
| 265 | const actorScopeKey = actorId ? scopeKeyForCredential("actor", actorId) : null; | ||
| 189 | 266 | ||
| 190 | const actorDoc = await ctx.db | 267 | const tryWorkspaceOwned = async (scopeKey: string) => { |
| 268 | return await ctx.db | ||
| 191 | .query("sourceCredentials") | 269 | .query("sourceCredentials") |
| 192 | .withIndex("by_workspace_source_scope_key", (q) => | 270 | .withIndex("by_workspace_source_scope_key", (q) => |
| 193 | q | 271 | q |
| 194 | .eq("workspaceId", args.workspaceId) | 272 | .eq("workspaceId", args.workspaceId) |
| 195 | .eq("sourceKey", args.sourceKey) | 273 | .eq("sourceKey", args.sourceKey) |
| 196 | .eq("scopeKey", scopeKeyForCredential("actor", actorId)), | 274 | .eq("scopeKey", scopeKey), |
| 197 | ) | 275 | ) |
| 198 | .unique(); | 276 | .unique(); |
| 277 | }; | ||
| 278 | |||
| 279 | const tryOrganizationOwned = async (scopeKey: string) => { | ||
| 280 | return await ctx.db | ||
| 281 | .query("sourceCredentials") | ||
| 282 | .withIndex("by_organization_owner_source_scope_key", (q) => | ||
| 283 | q | ||
| 284 | .eq("organizationId", workspace.organizationId) | ||
| 285 | .eq("ownerScopeType", "organization") | ||
| 286 | .eq("sourceKey", args.sourceKey) | ||
| 287 | .eq("scopeKey", scopeKey), | ||
| 288 | ) | ||
| 289 | .unique(); | ||
| 290 | }; | ||
| 291 | |||
| 292 | if (args.scope === "actor" && actorScopeKey) { | ||
| 293 | const workspaceActorDoc = await tryWorkspaceOwned(actorScopeKey); | ||
| 294 | if (workspaceActorDoc) { | ||
| 295 | return mapCredential(workspaceActorDoc as never); | ||
| 296 | } | ||
| 297 | |||
| 298 | const organizationActorDoc = await tryOrganizationOwned(actorScopeKey); | ||
| 299 | if (organizationActorDoc) { | ||
| 300 | return mapCredential(organizationActorDoc as never); | ||
| 301 | } | ||
| 302 | } | ||
| 303 | |||
| 304 | const workspaceDoc = await tryWorkspaceOwned(workspaceScopeKey); | ||
| 305 | if (workspaceDoc) { | ||
| 306 | return mapCredential(workspaceDoc as never); | ||
| 307 | } | ||
| 199 | 308 | ||
| 200 | return actorDoc ? mapCredential(actorDoc) : null; | 309 | const organizationDoc = await tryOrganizationOwned(workspaceScopeKey); |
| 310 | if (organizationDoc) { | ||
| 311 | return mapCredential(organizationDoc as never); | ||
| 201 | } | 312 | } |
| 202 | 313 | ||
| 203 | const workspaceDoc = await ctx.db | 314 | return null; |
| 204 | .query("sourceCredentials") | ||
| 205 | .withIndex("by_workspace_source_scope_key", (q) => | ||
| 206 | q | ||
| 207 | .eq("workspaceId", args.workspaceId) | ||
| 208 | .eq("sourceKey", args.sourceKey) | ||
| 209 | .eq("scopeKey", scopeKeyForCredential("workspace")), | ||
| 210 | ) | ||
| 211 | .unique(); | ||
| 212 | |||
| 213 | return workspaceDoc ? mapCredential(workspaceDoc) : null; | ||
| 214 | }, | 315 | }, |
| 215 | }); | 316 | }); |
| @@ -2,7 +2,26 @@ import { v } from "convex/values"; · 1 unmodified line | |||
| 2 | import { internalMutation, internalQuery } from "../_generated/server"; | 2 | import { internalMutation, internalQuery } from "../_generated/server"; |
| 3 | import { listRuntimeTargets as listAvailableRuntimeTargets } from "../../../core/src/runtimes/runtime-catalog"; | 3 | import { listRuntimeTargets as listAvailableRuntimeTargets } from "../../../core/src/runtimes/runtime-catalog"; |
| 4 | import { mapPolicy } from "../../src/database/mappers"; | 4 | import { mapPolicy } from "../../src/database/mappers"; |
| 5 | import { policyDecisionValidator } from "../../src/database/validators"; | 5 | import { |
| 6 | policyApprovalModeValidator, | ||
| 7 | policyDecisionValidator, | ||
| 8 | policyEffectValidator, | ||
| 9 | policyMatchTypeValidator, | ||
| 10 | policyScopeTypeValidator, | ||
| 11 | } from "../../src/database/validators"; | ||
| 12 | |||
| 13 | function parseDecision(value: "allow" | "require_approval" | "deny" | undefined): { | ||
| 14 | effect: "allow" | "deny"; | ||
| 15 | approvalMode: "inherit" | "auto" | "required"; | ||
| 16 | } { | ||
| 17 | if (value === "deny") { | ||
| 18 | return { effect: "deny", approvalMode: "inherit" }; | ||
| 19 | } | ||
| 20 | if (value === "require_approval") { | ||
| 21 | return { effect: "allow", approvalMode: "required" }; | ||
| 22 | } | ||
| 23 | return { effect: "allow", approvalMode: "auto" }; | ||
| 24 | } | ||
| 6 | 25 | ||
| 7 | export const listRuntimeTargets = internalQuery({ | 26 | export const listRuntimeTargets = internalQuery({ |
| 8 | args: {}, | 27 | args: {}, |
| @@ -15,15 +34,34 @@ export const upsertAccessPolicy = internalMutation({ · 6 unmodified lines | |||
| 15 | args: { | 34 | args: { |
| 16 | id: v.optional(v.string()), | 35 | id: v.optional(v.string()), |
| 17 | workspaceId: v.id("workspaces"), | 36 | workspaceId: v.id("workspaces"), |
| 37 | scopeType: v.optional(policyScopeTypeValidator), | ||
| 18 | actorId: v.optional(v.string()), | 38 | actorId: v.optional(v.string()), |
| 39 | targetActorId: v.optional(v.string()), | ||
| 19 | clientId: v.optional(v.string()), | 40 | clientId: v.optional(v.string()), |
| 20 | toolPathPattern: v.string(), | 41 | toolPathPattern: v.optional(v.string()), |
| 21 | decision: policyDecisionValidator, | 42 | resourcePattern: v.optional(v.string()), |
| 43 | matchType: v.optional(policyMatchTypeValidator), | ||
| 44 | decision: v.optional(policyDecisionValidator), | ||
| 45 | effect: v.optional(policyEffectValidator), | ||
| 46 | approvalMode: v.optional(policyApprovalModeValidator), | ||
| 22 | priority: v.optional(v.number()), | 47 | priority: v.optional(v.number()), |
| 23 | }, | 48 | }, |
| 24 | handler: async (ctx, args) => { | 49 | handler: async (ctx, args) => { |
| 25 | const now = Date.now(); | 50 | const now = Date.now(); |
| 26 | const policyId = args.id ?? `policy_${crypto.randomUUID()}`; | 51 | const policyId = args.id ?? `policy_${crypto.randomUUID()}`; |
| 52 | const workspace = await ctx.db.get(args.workspaceId); | ||
| 53 | if (!workspace) { | ||
| 54 | throw new Error(`Workspace not found: ${args.workspaceId}`); | ||
| 55 | } | ||
| 56 | |||
| 57 | const scopeType = args.scopeType ?? "workspace"; | ||
| 58 | const targetActorId = (args.targetActorId ?? args.actorId)?.trim() || undefined; | ||
| 59 | const resourcePattern = (args.resourcePattern ?? args.toolPathPattern ?? "*").trim() || "*"; | ||
| 60 | const matchType = args.matchType ?? "glob"; | ||
| 61 | const decisionFields = parseDecision(args.decision); | ||
| 62 | const effect = args.effect ?? decisionFields.effect; | ||
| 63 | const approvalMode = args.approvalMode ?? decisionFields.approvalMode; | ||
| 64 | |||
| 27 | const existing = await ctx.db | 65 | const existing = await ctx.db |
| 28 | .query("accessPolicies") | 66 | .query("accessPolicies") |
| 29 | .withIndex("by_policy_id", (q) => q.eq("policyId", policyId)) | 67 | .withIndex("by_policy_id", (q) => q.eq("policyId", policyId)) |
| @@ -31,22 +69,32 @@ export const upsertAccessPolicy = internalMutation({ · 1 unmodified line | |||
| 31 | 69 | ||
| 32 | if (existing) { | 70 | if (existing) { |
| 33 | await ctx.db.patch(existing._id, { | 71 | await ctx.db.patch(existing._id, { |
| 34 | workspaceId: args.workspaceId, | 72 | scopeType, |
| 35 | actorId: args.actorId?.trim() || undefined, | 73 | organizationId: workspace.organizationId, |
| 74 | workspaceId: scopeType === "workspace" ? args.workspaceId : undefined, | ||
| 75 | targetActorId, | ||
| 36 | clientId: args.clientId?.trim() || undefined, | 76 | clientId: args.clientId?.trim() || undefined, |
| 37 | toolPathPattern: args.toolPathPattern, | 77 | resourceType: "tool_path", |
| 38 | decision: args.decision, | 78 | resourcePattern, |
| 79 | matchType, | ||
| 80 | effect, | ||
| 81 | approvalMode, | ||
| 39 | priority: args.priority ?? 100, | 82 | priority: args.priority ?? 100, |
| 40 | updatedAt: now, | 83 | updatedAt: now, |
| 41 | }); | 84 | }); |
| 42 | } else { | 85 | } else { |
| 43 | await ctx.db.insert("accessPolicies", { | 86 | await ctx.db.insert("accessPolicies", { |
| 44 | policyId, | 87 | policyId, |
| 45 | workspaceId: args.workspaceId, | 88 | scopeType, |
| 46 | actorId: args.actorId?.trim() || undefined, | 89 | organizationId: workspace.organizationId, |
| 90 | workspaceId: scopeType === "workspace" ? args.workspaceId : undefined, | ||
| 91 | targetActorId, | ||
| 47 | clientId: args.clientId?.trim() || undefined, | 92 | clientId: args.clientId?.trim() || undefined, |
| 48 | toolPathPattern: args.toolPathPattern, | 93 | resourceType: "tool_path", |
| 49 | decision: args.decision, | 94 | resourcePattern, |
| 95 | matchType, | ||
| 96 | effect, | ||
| 97 | approvalMode, | ||
| 50 | priority: args.priority ?? 100, | 98 | priority: args.priority ?? 100, |
| 51 | createdAt: now, | 99 | createdAt: now, |
| 52 | updatedAt: now, | 100 | updatedAt: now, |
| @@ -67,12 +115,26 @@ export const upsertAccessPolicy = internalMutation({ · 14 unmodified lines | |||
| 67 | export const listAccessPolicies = internalQuery({ | 115 | export const listAccessPolicies = internalQuery({ |
| 68 | args: { workspaceId: v.id("workspaces") }, | 116 | args: { workspaceId: v.id("workspaces") }, |
| 69 | handler: async (ctx, args) => { | 117 | handler: async (ctx, args) => { |
| 70 | const docs = await ctx.db | 118 | const workspace = await ctx.db.get(args.workspaceId); |
| 119 | if (!workspace) { | ||
| 120 | return []; | ||
| 121 | } | ||
| 122 | |||
| 123 | const workspaceDocs = await ctx.db | ||
| 71 | .query("accessPolicies") | 124 | .query("accessPolicies") |
| 72 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | 125 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) |
| 73 | .collect(); | 126 | .collect(); |
| 74 | 127 | ||
| 75 | return docs | 128 | const organizationDocs = await ctx.db |
| 129 | .query("accessPolicies") | ||
| 130 | .withIndex("by_organization_created", (q) => q.eq("organizationId", workspace.organizationId)) | ||
| 131 | .collect(); | ||
| 132 | |||
| 133 | const all = [...workspaceDocs, ...organizationDocs.filter((doc) => doc.scopeType === "organization")].filter((doc, index, entries) => { | ||
| 134 | return entries.findIndex((candidate) => candidate.policyId === doc.policyId) === index; | ||
| 135 | }); | ||
| 136 | |||
| 137 | return all | ||
| 76 | .sort((a, b) => { | 138 | .sort((a, b) => { |
| 77 | if (a.priority !== b.priority) { | 139 | if (a.priority !== b.priority) { |
| 78 | return b.priority - a.priority; | 140 | return b.priority - a.priority; |
| @@ -2,7 +2,6 @@ import { v } from "convex/values"; · 1 unmodified line | |||
| 2 | import { internalMutation, internalQuery } from "../_generated/server"; | 2 | import { internalMutation, internalQuery } from "../_generated/server"; |
| 3 | import { getTaskDoc, mapTaskEvent } from "../../src/database/readers"; | 3 | import { getTaskDoc, mapTaskEvent } from "../../src/database/readers"; |
| 4 | import { jsonObjectValidator } from "../../src/database/validators"; | 4 | import { jsonObjectValidator } from "../../src/database/validators"; |
| 5 | import { asRecord } from "../../src/lib/object"; | ||
| 6 | 5 | ||
| 7 | export const createTaskEvent = internalMutation({ | 6 | export const createTaskEvent = internalMutation({ |
| 8 | args: { | 7 | args: { |
| @@ -30,7 +29,7 @@ export const createTaskEvent = internalMutation({ · 21 unmodified lines | |||
| 30 | taskId: args.taskId, | 29 | taskId: args.taskId, |
| 31 | eventName: args.eventName, | 30 | eventName: args.eventName, |
| 32 | type: args.type, | 31 | type: args.type, |
| 33 | payload: asRecord(args.payload), | 32 | payload: args.payload, |
| 34 | createdAt, | 33 | createdAt, |
| 35 | }); | 34 | }); |
| 36 | 35 | ||
| @@ -3,7 +3,6 @@ import { internalMutation, internalQuery } from "../_generated/server"; · 2 unmodified lines | |||
| 3 | import { mapTask } from "../../src/database/mappers"; | 3 | import { mapTask } from "../../src/database/mappers"; |
| 4 | import { getTaskDoc } from "../../src/database/readers"; | 4 | import { getTaskDoc } from "../../src/database/readers"; |
| 5 | import { completedTaskStatusValidator, jsonObjectValidator } from "../../src/database/validators"; | 5 | import { completedTaskStatusValidator, jsonObjectValidator } from "../../src/database/validators"; |
| 6 | import { asRecord } from "../../src/lib/object"; | ||
| 7 | import { DEFAULT_TASK_TIMEOUT_MS } from "../../src/task/constants"; | 6 | import { DEFAULT_TASK_TIMEOUT_MS } from "../../src/task/constants"; |
| 8 | import { isTerminalTaskStatus } from "../../src/task/status"; | 7 | import { isTerminalTaskStatus } from "../../src/task/status"; |
| 9 | 8 | ||
| @@ -25,6 +24,9 @@ export const createTask = internalMutation({ · 15 unmodified lines | |||
| 25 | } | 24 | } |
| 26 | 25 | ||
| 27 | const now = Date.now(); | 26 | const now = Date.now(); |
| 27 | const metadata = args.metadata === undefined | ||
| 28 | ? {} | ||
| 29 | : args.metadata; | ||
| 28 | await ctx.db.insert("tasks", { | 30 | await ctx.db.insert("tasks", { |
| 29 | taskId: args.id, | 31 | taskId: args.id, |
| 30 | code: args.code, | 32 | code: args.code, |
| @@ -34,7 +36,7 @@ export const createTask = internalMutation({ · 3 unmodified lines | |||
| 34 | clientId: args.clientId?.trim() || undefined, | 36 | clientId: args.clientId?.trim() || undefined, |
| 35 | status: "queued", | 37 | status: "queued", |
| 36 | timeoutMs: args.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS, | 38 | timeoutMs: args.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS, |
| 37 | metadata: asRecord(args.metadata), | 39 | metadata, |
| 38 | createdAt: now, | 40 | createdAt: now, |
| 39 | updatedAt: now, | 41 | updatedAt: now, |
| 40 | }); | 42 | }); |
| @@ -6,12 +6,13 @@ import { · 5 unmodified lines | |||
| 6 | normalizeSourceAuthFingerprint, | 6 | normalizeSourceAuthFingerprint, |
| 7 | } from "../../src/database/mappers"; | 7 | } from "../../src/database/mappers"; |
| 8 | import { normalizeToolSourceConfig } from "../../src/database/tool_source_config"; | 8 | import { normalizeToolSourceConfig } from "../../src/database/tool_source_config"; |
| 9 | import { jsonObjectValidator, toolSourceTypeValidator } from "../../src/database/validators"; | 9 | import { jsonObjectValidator, ownerScopeTypeValidator, toolSourceTypeValidator } from "../../src/database/validators"; |
| 10 | 10 | ||
| 11 | export const upsertToolSource = internalMutation({ | 11 | export const upsertToolSource = internalMutation({ |
| 12 | args: { | 12 | args: { |
| 13 | id: v.optional(v.string()), | 13 | id: v.optional(v.string()), |
| 14 | workspaceId: v.id("workspaces"), | 14 | workspaceId: v.id("workspaces"), |
| 15 | ownerScopeType: v.optional(ownerScopeTypeValidator), | ||
| 15 | name: v.string(), | 16 | name: v.string(), |
| 16 | type: toolSourceTypeValidator, | 17 | type: toolSourceTypeValidator, |
| 17 | config: jsonObjectValidator, | 18 | config: jsonObjectValidator, |
| @@ -20,6 +21,14 @@ export const upsertToolSource = internalMutation({ · 2 unmodified lines | |||
| 20 | handler: async (ctx, args) => { | 21 | handler: async (ctx, args) => { |
| 21 | const now = Date.now(); | 22 | const now = Date.now(); |
| 22 | const sourceId = args.id ?? `src_${crypto.randomUUID()}`; | 23 | const sourceId = args.id ?? `src_${crypto.randomUUID()}`; |
| 24 | const ownerScopeType = args.ownerScopeType ?? "workspace"; | ||
| 25 | const workspace = await ctx.db.get(args.workspaceId); | ||
| 26 | if (!workspace) { | ||
| 27 | throw new Error(`Workspace not found: ${args.workspaceId}`); | ||
| 28 | } | ||
| 29 | const organizationId = workspace.organizationId; | ||
| 30 | const ownerWorkspaceId = ownerScopeType === "workspace" ? args.workspaceId : undefined; | ||
| 31 | |||
| 23 | const configResult = normalizeToolSourceConfig(args.type, args.config); | 32 | const configResult = normalizeToolSourceConfig(args.type, args.config); |
| 24 | if (configResult.isErr()) { | 33 | if (configResult.isErr()) { |
| 25 | throw new Error(configResult.error.message); | 34 | throw new Error(configResult.error.message); |
| @@ -32,19 +41,28 @@ export const upsertToolSource = internalMutation({ · 6 unmodified lines | |||
| 32 | .query("toolSources") | 41 | .query("toolSources") |
| 33 | .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) | 42 | .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) |
| 34 | .unique(), | 43 | .unique(), |
| 35 | ctx.db | 44 | ownerScopeType === "workspace" |
| 36 | .query("toolSources") | 45 | ? ctx.db |
| 37 | .withIndex("by_workspace_name", (q) => q.eq("workspaceId", args.workspaceId).eq("name", args.name)) | 46 | .query("toolSources") |
| 38 | .unique(), | 47 | .withIndex("by_workspace_name", (q) => q.eq("workspaceId", args.workspaceId).eq("name", args.name)) |
| 48 | .unique() | ||
| 49 | : ctx.db | ||
| 50 | .query("toolSources") | ||
| 51 | .withIndex("by_organization_owner_name", (q) => | ||
| 52 | q.eq("organizationId", organizationId).eq("ownerScopeType", "organization").eq("name", args.name), | ||
| 53 | ) | ||
| 54 | .unique(), | ||
| 39 | ]); | 55 | ]); |
| 40 | 56 | ||
| 41 | if (conflict && conflict.sourceId !== sourceId) { | 57 | if (conflict && conflict.sourceId !== sourceId) { |
| 42 | throw new Error(`Tool source name '${args.name}' already exists in workspace ${args.workspaceId}`); | 58 | throw new Error(`Tool source name '${args.name}' already exists in this scope`); |
| 43 | } | 59 | } |
| 44 | 60 | ||
| 45 | if (existing) { | 61 | if (existing) { |
| 46 | await ctx.db.patch(existing._id, { | 62 | await ctx.db.patch(existing._id, { |
| 47 | workspaceId: args.workspaceId, | 63 | ownerScopeType, |
| 64 | organizationId, | ||
| 65 | workspaceId: ownerWorkspaceId, | ||
| 48 | name: args.name, | 66 | name: args.name, |
| 49 | type: args.type, | 67 | type: args.type, |
| 50 | config, | 68 | config, |
| @@ -56,7 +74,9 @@ export const upsertToolSource = internalMutation({ · 5 unmodified lines | |||
| 56 | } else { | 74 | } else { |
| 57 | await ctx.db.insert("toolSources", { | 75 | await ctx.db.insert("toolSources", { |
| 58 | sourceId, | 76 | sourceId, |
| 59 | workspaceId: args.workspaceId, | 77 | ownerScopeType, |
| 78 | organizationId, | ||
| 79 | workspaceId: ownerWorkspaceId, | ||
| 60 | name: args.name, | 80 | name: args.name, |
| 61 | type: args.type, | 81 | type: args.type, |
| 62 | config, | 82 | config, |
| @@ -82,11 +102,30 @@ export const upsertToolSource = internalMutation({ · 19 unmodified lines | |||
| 82 | export const listToolSources = internalQuery({ | 102 | export const listToolSources = internalQuery({ |
| 83 | args: { workspaceId: v.id("workspaces") }, | 103 | args: { workspaceId: v.id("workspaces") }, |
| 84 | handler: async (ctx, args) => { | 104 | handler: async (ctx, args) => { |
| 85 | const docs = await ctx.db | 105 | const workspace = await ctx.db.get(args.workspaceId); |
| 86 | .query("toolSources") | 106 | if (!workspace) { |
| 87 | .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId)) | 107 | return []; |
| 88 | .order("desc") | 108 | } |
| 89 | .collect(); | 109 | |
| 110 | const [workspaceDocs, organizationDocs] = await Promise.all([ | ||
| 111 | ctx.db | ||
| 112 | .query("toolSources") | ||
| 113 | .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 114 | .order("desc") | ||
| 115 | .collect(), | ||
| 116 | ctx.db | ||
| 117 | .query("toolSources") | ||
| 118 | .withIndex("by_organization_owner_updated", (q) => | ||
| 119 | q.eq("organizationId", workspace.organizationId).eq("ownerScopeType", "organization"), | ||
| 120 | ) | ||
| 121 | .order("desc") | ||
| 122 | .collect(), | ||
| 123 | ]); | ||
| 124 | |||
| 125 | const docs = [...workspaceDocs, ...organizationDocs] | ||
| 126 | .filter((doc, index, entries) => entries.findIndex((candidate) => candidate.sourceId === doc.sourceId) === index) | ||
| 127 | .sort((a, b) => b.updatedAt - a.updatedAt); | ||
| 128 | |||
| 90 | return docs.map(mapSource); | 129 | return docs.map(mapSource); |
| 91 | }, | 130 | }, |
| 92 | }); | 131 | }); |
| @@ -94,20 +133,29 @@ export const listToolSources = internalQuery({ · 1 unmodified line | |||
| 94 | export const deleteToolSource = internalMutation({ | 133 | export const deleteToolSource = internalMutation({ |
| 95 | args: { workspaceId: v.id("workspaces"), sourceId: v.string() }, | 134 | args: { workspaceId: v.id("workspaces"), sourceId: v.string() }, |
| 96 | handler: async (ctx, args) => { | 135 | handler: async (ctx, args) => { |
| 136 | const workspace = await ctx.db.get(args.workspaceId); | ||
| 137 | if (!workspace) { | ||
| 138 | return false; | ||
| 139 | } | ||
| 140 | |||
| 97 | const doc = await ctx.db | 141 | const doc = await ctx.db |
| 98 | .query("toolSources") | 142 | .query("toolSources") |
| 99 | .withIndex("by_source_id", (q) => q.eq("sourceId", args.sourceId)) | 143 | .withIndex("by_source_id", (q) => q.eq("sourceId", args.sourceId)) |
| 100 | .unique(); | 144 | .unique(); |
| 101 | 145 | ||
| 102 | if (!doc || doc.workspaceId !== args.workspaceId) { | 146 | if (!doc || doc.organizationId !== workspace.organizationId) { |
| 147 | return false; | ||
| 148 | } | ||
| 149 | |||
| 150 | if (doc.ownerScopeType === "workspace" && doc.workspaceId !== args.workspaceId) { | ||
| 103 | return false; | 151 | return false; |
| 104 | } | 152 | } |
| 105 | 153 | ||
| 106 | const sourceKey = `source:${args.sourceId}`; | 154 | const sourceKey = `source:${args.sourceId}`; |
| 107 | const bindings = await ctx.db | 155 | const bindings = await ctx.db |
| 108 | .query("sourceCredentials") | 156 | .query("sourceCredentials") |
| 109 | .withIndex("by_workspace_source_scope_key", (q) => | 157 | .withIndex("by_organization_source", (q) => |
| 110 | q.eq("workspaceId", args.workspaceId).eq("sourceKey", sourceKey), | 158 | q.eq("organizationId", workspace.organizationId).eq("sourceKey", sourceKey), |
| 111 | ) | 159 | ) |
| 112 | .collect(); | 160 | .collect(); |
| 113 | 161 | ||
| @@ -127,9 +127,11 @@ export const cleanupTaskEmptyStringSentinels = migrations.define({ · 126 unmodified lines | |||
| 127 | export const cleanupAccessPolicyEmptyStringSentinels = migrations.define({ | 127 | export const cleanupAccessPolicyEmptyStringSentinels = migrations.define({ |
| 128 | table: "accessPolicies", | 128 | table: "accessPolicies", |
| 129 | migrateOne: async (_ctx, policy) => { | 129 | migrateOne: async (_ctx, policy) => { |
| 130 | const policyRecord = policy as Record<string, unknown>; | ||
| 130 | const patch: Record<string, undefined> = {}; | 131 | const patch: Record<string, undefined> = {}; |
| 131 | if (policy.actorId === "") patch.actorId = undefined; | 132 | if (Reflect.get(policyRecord, "actorId") === "") patch["actorId"] = undefined; |
| 132 | if (policy.clientId === "") patch.clientId = undefined; | 133 | if (Reflect.get(policyRecord, "targetActorId") === "") patch["targetActorId"] = undefined; |
| 134 | if (policy.clientId === "") patch["clientId"] = undefined; | ||
| 133 | if (Object.keys(patch).length > 0) return patch; | 135 | if (Object.keys(patch).length > 0) return patch; |
| 134 | }, | 136 | }, |
| 135 | }); | 137 | }); |
| @@ -56,7 +56,12 @@ const toolCallStatus = v.union( · 55 unmodified lines | |||
| 56 | v.literal("denied"), | 56 | v.literal("denied"), |
| 57 | ); | 57 | ); |
| 58 | const toolApprovalMode = v.union(v.literal("auto"), v.literal("required")); | 58 | const toolApprovalMode = v.union(v.literal("auto"), v.literal("required")); |
| 59 | const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | 59 | const policyScopeType = v.union(v.literal("organization"), v.literal("workspace")); |
| 60 | const policyResourceType = v.literal("tool_path"); | ||
| 61 | const policyMatchType = v.union(v.literal("glob"), v.literal("exact")); | ||
| 62 | const policyEffect = v.union(v.literal("allow"), v.literal("deny")); | ||
| 63 | const policyApprovalMode = v.union(v.literal("inherit"), v.literal("auto"), v.literal("required")); | ||
| 64 | const ownerScopeType = v.union(v.literal("organization"), v.literal("workspace")); | ||
| 60 | const credentialScope = v.union(v.literal("workspace"), v.literal("actor")); | 65 | const credentialScope = v.union(v.literal("workspace"), v.literal("actor")); |
| 61 | const credentialProvider = v.union( | 66 | const credentialProvider = v.union( |
| 62 | v.literal("local-convex"), | 67 | v.literal("local-convex"), |
| @@ -319,36 +324,46 @@ export default defineSchema({ · 256 unmodified lines | |||
| 319 | }) | 324 | }) |
| 320 | .index("by_task_sequence", ["taskId", "sequence"]), | 325 | .index("by_task_sequence", ["taskId", "sequence"]), |
| 321 | 326 | ||
| 322 | // Workspace access policy rules used by the approval / tool firewall. | 327 | // Workspace + organization access policy rules used by the approval / tool firewall. |
| 323 | // `toolPathPattern` is matched against tool paths and combined with actor/client selectors. | 328 | // Rules can target specific actors and clients, and can match tool paths either exactly |
| 324 | // Higher `priority` wins when multiple policies match. | 329 | // or with a glob pattern. |
| 325 | accessPolicies: defineTable({ | 330 | accessPolicies: defineTable({ |
| 326 | policyId: v.string(), // domain ID: policy_<uuid> | 331 | policyId: v.string(), // domain ID: policy_<uuid> |
| 327 | workspaceId: v.id("workspaces"), | 332 | scopeType: policyScopeType, |
| 328 | actorId: v.optional(v.string()), // account._id or anon_<uuid> | 333 | organizationId: v.id("organizations"), |
| 334 | workspaceId: v.optional(v.id("workspaces")), | ||
| 335 | targetActorId: v.optional(v.string()), // account._id or anon_<uuid> | ||
| 329 | clientId: v.optional(v.string()), // client label: "web", "mcp", etc. | 336 | clientId: v.optional(v.string()), // client label: "web", "mcp", etc. |
| 330 | toolPathPattern: v.string(), | 337 | resourceType: policyResourceType, |
| 331 | decision: policyDecision, | 338 | resourcePattern: v.string(), |
| 339 | matchType: policyMatchType, | ||
| 340 | effect: policyEffect, | ||
| 341 | approvalMode: policyApprovalMode, | ||
| 332 | priority: v.number(), | 342 | priority: v.number(), |
| 333 | createdAt: v.number(), | 343 | createdAt: v.number(), |
| 334 | updatedAt: v.number(), | 344 | updatedAt: v.number(), |
| 335 | }) | 345 | }) |
| 336 | .index("by_policy_id", ["policyId"]) | 346 | .index("by_policy_id", ["policyId"]) |
| 337 | .index("by_workspace_created", ["workspaceId", "createdAt"]), | 347 | .index("by_workspace_created", ["workspaceId", "createdAt"]) |
| 348 | .index("by_organization_created", ["organizationId", "createdAt"]), | ||
| 338 | 349 | ||
| 339 | // Stored credentials for tool sources. | 350 | // Stored credentials for tool sources. |
| 340 | // | 351 | // |
| 341 | // A single credential "connection" (credentialId) can have multiple rows to support | 352 | // A single credential "connection" (credentialId) can have multiple rows to support |
| 342 | // different bindings (workspace-wide and per-actor). `bindingId` exists as a stable handle | 353 | // different bindings (workspace-wide and per-actor), and can be owned by either a |
| 354 | // workspace or an organization. | ||
| 355 | // `bindingId` exists as a stable handle | ||
| 343 | // for UI/API operations that need an id before the connection id is known. | 356 | // for UI/API operations that need an id before the connection id is known. |
| 344 | // | 357 | // |
| 345 | // Primary access patterns: | 358 | // Primary access patterns: |
| 346 | // - Resolve by (workspaceId, sourceKey, scopeKey). | 359 | // - Resolve by (workspaceId|organizationId, sourceKey, scopeKey). |
| 347 | // - List all credentials in workspace by createdAt. | 360 | // - List effective credentials for a workspace by createdAt. |
| 348 | sourceCredentials: defineTable({ | 361 | sourceCredentials: defineTable({ |
| 349 | bindingId: v.string(), // domain ID: bind_<uuid> | 362 | bindingId: v.string(), // domain ID: bind_<uuid> |
| 350 | credentialId: v.string(), // domain ID: conn_<uuid> | 363 | credentialId: v.string(), // domain ID: conn_<uuid> |
| 351 | workspaceId: v.id("workspaces"), | 364 | ownerScopeType: ownerScopeType, |
| 365 | organizationId: v.id("organizations"), | ||
| 366 | workspaceId: v.optional(v.id("workspaces")), | ||
| 352 | sourceKey: v.string(), | 367 | sourceKey: v.string(), |
| 353 | scope: credentialScope, | 368 | scope: credentialScope, |
| 354 | scopeKey: v.string(), // "workspace" or actor:<actorId> | 369 | scopeKey: v.string(), // "workspace" or actor:<actorId> |
| @@ -361,21 +376,30 @@ export default defineSchema({ · 6 unmodified lines | |||
| 361 | updatedAt: v.number(), | 376 | updatedAt: v.number(), |
| 362 | }) | 377 | }) |
| 363 | .index("by_workspace_created", ["workspaceId", "createdAt"]) | 378 | .index("by_workspace_created", ["workspaceId", "createdAt"]) |
| 379 | .index("by_organization_created", ["organizationId", "createdAt"]) | ||
| 380 | .index("by_organization_owner_created", ["organizationId", "ownerScopeType", "createdAt"]) | ||
| 364 | .index("by_workspace_source_scope_key", ["workspaceId", "sourceKey", "scopeKey"]) | 381 | .index("by_workspace_source_scope_key", ["workspaceId", "sourceKey", "scopeKey"]) |
| 382 | .index("by_organization_source_scope_key", ["organizationId", "sourceKey", "scopeKey"]) | ||
| 383 | .index("by_organization_owner_source_scope_key", ["organizationId", "ownerScopeType", "sourceKey", "scopeKey"]) | ||
| 365 | .index("by_workspace_credential", ["workspaceId", "credentialId"]) | 384 | .index("by_workspace_credential", ["workspaceId", "credentialId"]) |
| 385 | .index("by_organization_credential", ["organizationId", "credentialId"]) | ||
| 386 | .index("by_organization_owner_credential", ["organizationId", "ownerScopeType", "credentialId"]) | ||
| 387 | .index("by_organization_source", ["organizationId", "sourceKey"]) | ||
| 366 | .index("by_binding_id", ["bindingId"]), | 388 | .index("by_binding_id", ["bindingId"]), |
| 367 | 389 | ||
| 368 | // Configured tool sources for a workspace (MCP servers, OpenAPI sources, GraphQL sources). | 390 | // Configured tool sources for a workspace/organization (MCP servers, OpenAPI sources, GraphQL sources). |
| 369 | // `specHash` enables cache invalidation when the definition changes. | 391 | // `specHash` enables cache invalidation when the definition changes. |
| 370 | // `authFingerprint` is used to determine whether cached tool materialization is still valid. | 392 | // `authFingerprint` is used to determine whether cached tool materialization is still valid. |
| 371 | // | 393 | // |
| 372 | // Primary access patterns: | 394 | // Primary access patterns: |
| 373 | // - Resolve by domain source id. | 395 | // - Resolve by domain source id. |
| 374 | // - List sources by workspace, sorted by updatedAt. | 396 | // - List effective sources by workspace, sorted by updatedAt. |
| 375 | // - Enforce name uniqueness per workspace. | 397 | // - Enforce name uniqueness per owner scope. |
| 376 | toolSources: defineTable({ | 398 | toolSources: defineTable({ |
| 377 | sourceId: v.string(), // domain ID: src_<uuid> | 399 | sourceId: v.string(), // domain ID: src_<uuid> |
| 378 | workspaceId: v.id("workspaces"), | 400 | ownerScopeType: ownerScopeType, |
| 401 | organizationId: v.id("organizations"), | ||
| 402 | workspaceId: v.optional(v.id("workspaces")), | ||
| 379 | name: v.string(), | 403 | name: v.string(), |
| 380 | type: toolSourceType, | 404 | type: toolSourceType, |
| 381 | config: jsonObject, | 405 | config: jsonObject, |
| @@ -387,7 +411,11 @@ export default defineSchema({ · 5 unmodified lines | |||
| 387 | }) | 411 | }) |
| 388 | .index("by_source_id", ["sourceId"]) | 412 | .index("by_source_id", ["sourceId"]) |
| 389 | .index("by_workspace_updated", ["workspaceId", "updatedAt"]) | 413 | .index("by_workspace_updated", ["workspaceId", "updatedAt"]) |
| 390 | .index("by_workspace_name", ["workspaceId", "name"]), | 414 | .index("by_organization_updated", ["organizationId", "updatedAt"]) |
| 415 | .index("by_organization_owner_updated", ["organizationId", "ownerScopeType", "updatedAt"]) | ||
| 416 | .index("by_workspace_name", ["workspaceId", "name"]) | ||
| 417 | .index("by_organization_name", ["organizationId", "name"]) | ||
| 418 | .index("by_organization_owner_name", ["organizationId", "ownerScopeType", "name"]), | ||
| 391 | 419 | ||
| 392 | // Cached OpenAPI spec blobs stored in Convex storage. | 420 | // Cached OpenAPI spec blobs stored in Convex storage. |
| 393 | // (specUrl, version) uniquely identifies a stored spec payload. | 421 | // (specUrl, version) uniquely identifies a stored spec payload. |
| @@ -176,6 +176,68 @@ export const finishBuild = internalMutation({ · 175 unmodified lines | |||
| 176 | }, | 176 | }, |
| 177 | }); | 177 | }); |
| 178 | 178 | ||
| 179 | export const pruneBuilds = internalMutation({ | ||
| 180 | args: { | ||
| 181 | workspaceId: v.id("workspaces"), | ||
| 182 | maxRetainedBuilds: v.optional(v.number()), | ||
| 183 | }, | ||
| 184 | handler: async (ctx, args) => { | ||
| 185 | const maxRetainedBuilds = Math.max(1, Math.min(10, Math.floor(args.maxRetainedBuilds ?? 2))); | ||
| 186 | const state = await ctx.db | ||
| 187 | .query("workspaceToolRegistryState") | ||
| 188 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 189 | .unique(); | ||
| 190 | |||
| 191 | const protectedBuildIds = new Set<string>(); | ||
| 192 | if (state?.readyBuildId) protectedBuildIds.add(state.readyBuildId); | ||
| 193 | if (state?.buildingBuildId) protectedBuildIds.add(state.buildingBuildId); | ||
| 194 | |||
| 195 | const toolEntries = await ctx.db | ||
| 196 | .query("workspaceToolRegistry") | ||
| 197 | .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 198 | .collect(); | ||
| 199 | |||
| 200 | const namespaceEntries = await ctx.db | ||
| 201 | .query("workspaceToolNamespaces") | ||
| 202 | .withIndex("by_workspace_build", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 203 | .collect(); | ||
| 204 | |||
| 205 | const latestCreatedAtByBuild = new Map<string, number>(); | ||
| 206 | for (const entry of toolEntries) { | ||
| 207 | const current = latestCreatedAtByBuild.get(entry.buildId) ?? 0; | ||
| 208 | if (entry.createdAt > current) { | ||
| 209 | latestCreatedAtByBuild.set(entry.buildId, entry.createdAt); | ||
| 210 | } | ||
| 211 | } | ||
| 212 | for (const entry of namespaceEntries) { | ||
| 213 | const current = latestCreatedAtByBuild.get(entry.buildId) ?? 0; | ||
| 214 | if (entry.createdAt > current) { | ||
| 215 | latestCreatedAtByBuild.set(entry.buildId, entry.createdAt); | ||
| 216 | } | ||
| 217 | } | ||
| 218 | |||
| 219 | const buildIdsByRecency = [...latestCreatedAtByBuild.entries()] | ||
| 220 | .sort((a, b) => b[1] - a[1]) | ||
| 221 | .map(([buildId]) => buildId); | ||
| 222 | |||
| 223 | const retained = new Set<string>(buildIdsByRecency.slice(0, maxRetainedBuilds)); | ||
| 224 | for (const protectedBuildId of protectedBuildIds) { | ||
| 225 | retained.add(protectedBuildId); | ||
| 226 | } | ||
| 227 | |||
| 228 | for (const entry of toolEntries) { | ||
| 229 | if (!retained.has(entry.buildId)) { | ||
| 230 | await ctx.db.delete(entry._id); | ||
| 231 | } | ||
| 232 | } | ||
| 233 | for (const entry of namespaceEntries) { | ||
| 234 | if (!retained.has(entry.buildId)) { | ||
| 235 | await ctx.db.delete(entry._id); | ||
| 236 | } | ||
| 237 | } | ||
| 238 | }, | ||
| 239 | }); | ||
| 240 | |||
| 179 | export const getToolByPath = internalQuery({ | 241 | export const getToolByPath = internalQuery({ |
| 180 | args: { | 242 | args: { |
| 181 | workspaceId: v.id("workspaces"), | 243 | workspaceId: v.id("workspaces"), |
| @@ -2,11 +2,17 @@ import { v } from "convex/values"; · 1 unmodified line | |||
| 2 | import { internal } from "./_generated/api"; | 2 | import { internal } from "./_generated/api"; |
| 3 | import { mutation } from "./_generated/server"; | 3 | import { mutation } from "./_generated/server"; |
| 4 | import { workspaceMutation, workspaceQuery } from "../../core/src/function-builders"; | 4 | import { workspaceMutation, workspaceQuery } from "../../core/src/function-builders"; |
| 5 | import { getOrganizationMembership, isAdminRole } from "../../core/src/identity"; | ||
| 5 | import { | 6 | import { |
| 6 | credentialProviderValidator, | 7 | credentialProviderValidator, |
| 7 | credentialScopeValidator, | 8 | credentialScopeValidator, |
| 8 | jsonObjectValidator, | 9 | jsonObjectValidator, |
| 10 | ownerScopeTypeValidator, | ||
| 11 | policyApprovalModeValidator, | ||
| 9 | policyDecisionValidator, | 12 | policyDecisionValidator, |
| 13 | policyEffectValidator, | ||
| 14 | policyMatchTypeValidator, | ||
| 15 | policyScopeTypeValidator, | ||
| 10 | toolSourceTypeValidator, | 16 | toolSourceTypeValidator, |
| 11 | } from "../src/database/validators"; | 17 | } from "../src/database/validators"; |
| 12 | 18 | ||
| @@ -43,13 +49,24 @@ export const upsertAccessPolicy = workspaceMutation({ · 30 unmodified lines | |||
| 43 | requireAdmin: true, | 49 | requireAdmin: true, |
| 44 | args: { | 50 | args: { |
| 45 | id: v.optional(v.string()), | 51 | id: v.optional(v.string()), |
| 52 | scopeType: v.optional(policyScopeTypeValidator), | ||
| 46 | actorId: v.optional(v.string()), | 53 | actorId: v.optional(v.string()), |
| 47 | clientId: v.optional(v.string()), | 54 | clientId: v.optional(v.string()), |
| 48 | toolPathPattern: v.string(), | 55 | toolPathPattern: v.string(), |
| 49 | decision: policyDecisionValidator, | 56 | decision: v.optional(policyDecisionValidator), |
| 57 | matchType: v.optional(policyMatchTypeValidator), | ||
| 58 | effect: v.optional(policyEffectValidator), | ||
| 59 | approvalMode: v.optional(policyApprovalModeValidator), | ||
| 50 | priority: v.optional(v.number()), | 60 | priority: v.optional(v.number()), |
| 51 | }, | 61 | }, |
| 52 | handler: async (ctx, args) => { | 62 | handler: async (ctx, args) => { |
| 63 | if (args.scopeType === "organization") { | ||
| 64 | const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id); | ||
| 65 | if (!organizationMembership || !isAdminRole(organizationMembership.role)) { | ||
| 66 | throw new Error("Only organization admins can create organization-level policies"); | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 53 | return await ctx.runMutation(internal.database.upsertAccessPolicy, { | 70 | return await ctx.runMutation(internal.database.upsertAccessPolicy, { |
| 54 | ...args, | 71 | ...args, |
| 55 | workspaceId: ctx.workspaceId, | 72 | workspaceId: ctx.workspaceId, |
| @@ -70,6 +87,7 @@ export const upsertCredential = workspaceMutation({ · 14 unmodified lines | |||
| 70 | requireAdmin: true, | 87 | requireAdmin: true, |
| 71 | args: { | 88 | args: { |
| 72 | id: v.optional(v.string()), | 89 | id: v.optional(v.string()), |
| 90 | ownerScopeType: v.optional(ownerScopeTypeValidator), | ||
| 73 | sourceKey: v.string(), | 91 | sourceKey: v.string(), |
| 74 | scope: credentialScopeValidator, | 92 | scope: credentialScopeValidator, |
| 75 | actorId: v.optional(v.string()), | 93 | actorId: v.optional(v.string()), |
| @@ -78,6 +96,13 @@ export const upsertCredential = workspaceMutation({ · 2 unmodified lines | |||
| 78 | overridesJson: v.optional(jsonObjectValidator), | 96 | overridesJson: v.optional(jsonObjectValidator), |
| 79 | }, | 97 | }, |
| 80 | handler: async (ctx, args) => { | 98 | handler: async (ctx, args) => { |
| 99 | if (args.ownerScopeType === "organization") { | ||
| 100 | const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id); | ||
| 101 | if (!organizationMembership || !isAdminRole(organizationMembership.role)) { | ||
| 102 | throw new Error("Only organization admins can manage organization-level credentials"); | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 81 | return await ctx.runMutation(internal.database.upsertCredential, { | 106 | return await ctx.runMutation(internal.database.upsertCredential, { |
| 82 | ...args, | 107 | ...args, |
| 83 | workspaceId: ctx.workspaceId, | 108 | workspaceId: ctx.workspaceId, |
| @@ -122,12 +147,20 @@ export const upsertToolSource = workspaceMutation({ · 38 unmodified lines | |||
| 122 | requireAdmin: true, | 147 | requireAdmin: true, |
| 123 | args: { | 148 | args: { |
| 124 | id: v.optional(v.string()), | 149 | id: v.optional(v.string()), |
| 150 | ownerScopeType: v.optional(ownerScopeTypeValidator), | ||
| 125 | name: v.string(), | 151 | name: v.string(), |
| 126 | type: toolSourceTypeValidator, | 152 | type: toolSourceTypeValidator, |
| 127 | config: jsonObjectValidator, | 153 | config: jsonObjectValidator, |
| 128 | enabled: v.optional(v.boolean()), | 154 | enabled: v.optional(v.boolean()), |
| 129 | }, | 155 | }, |
| 130 | handler: async (ctx, args) => { | 156 | handler: async (ctx, args) => { |
| 157 | if (args.ownerScopeType === "organization") { | ||
| 158 | const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id); | ||
| 159 | if (!organizationMembership || !isAdminRole(organizationMembership.role)) { | ||
| 160 | throw new Error("Only organization admins can manage organization-level tool sources"); | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 131 | return await ctx.runMutation(internal.database.upsertToolSource, { | 164 | return await ctx.runMutation(internal.database.upsertToolSource, { |
| 132 | ...args, | 165 | ...args, |
| 133 | workspaceId: ctx.workspaceId, | 166 | workspaceId: ctx.workspaceId, |
| @@ -150,6 +183,23 @@ export const deleteToolSource = workspaceMutation({ · 16 unmodified lines | |||
| 150 | sourceId: v.string(), | 183 | sourceId: v.string(), |
| 151 | }, | 184 | }, |
| 152 | handler: async (ctx, args) => { | 185 | handler: async (ctx, args) => { |
| 186 | const sources = await ctx.runQuery(internal.database.listToolSources, { | ||
| 187 | workspaceId: ctx.workspaceId, | ||
| 188 | }); | ||
| 189 | let source: { id: string; ownerScopeType?: "organization" | "workspace" } | undefined; | ||
| 190 | for (const entry of sources as Array<{ id: string; ownerScopeType?: "organization" | "workspace" }>) { | ||
| 191 | if (entry.id === args.sourceId) { | ||
| 192 | source = entry; | ||
| 193 | break; | ||
| 194 | } | ||
| 195 | } | ||
| 196 | if (source?.ownerScopeType === "organization") { | ||
| 197 | const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id); | ||
| 198 | if (!organizationMembership || !isAdminRole(organizationMembership.role)) { | ||
| 199 | throw new Error("Only organization admins can delete organization-level tool sources"); | ||
| 200 | } | ||
| 201 | } | ||
| 202 | |||
| 153 | return await ctx.runMutation(internal.database.deleteToolSource, { | 203 | return await ctx.runMutation(internal.database.deleteToolSource, { |
| 154 | ...args, | 204 | ...args, |
| 155 | workspaceId: ctx.workspaceId, | 205 | workspaceId: ctx.workspaceId, |
| @@ -135,6 +135,30 @@ async function deleteOrganizationData( · 134 unmodified lines | |||
| 135 | await ctx.db.delete(seatState._id); | 135 | await ctx.db.delete(seatState._id); |
| 136 | } | 136 | } |
| 137 | 137 | ||
| 138 | const policyDocs = await ctx.db | ||
| 139 | .query("accessPolicies") | ||
| 140 | .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId)) | ||
| 141 | .collect(); | ||
| 142 | for (const policy of policyDocs) { | ||
| 143 | await ctx.db.delete(policy._id); | ||
| 144 | } | ||
| 145 | |||
| 146 | const credentialDocs = await ctx.db | ||
| 147 | .query("sourceCredentials") | ||
| 148 | .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId)) | ||
| 149 | .collect(); | ||
| 150 | for (const credential of credentialDocs) { | ||
| 151 | await ctx.db.delete(credential._id); | ||
| 152 | } | ||
| 153 | |||
| 154 | const toolSources = await ctx.db | ||
| 155 | .query("toolSources") | ||
| 156 | .withIndex("by_organization_updated", (q) => q.eq("organizationId", organizationId)) | ||
| 157 | .collect(); | ||
| 158 | for (const source of toolSources) { | ||
| 159 | await ctx.db.delete(source._id); | ||
| 160 | } | ||
| 161 | |||
| 138 | const workspaces = await ctx.db | 162 | const workspaces = await ctx.db |
| 139 | .query("workspaces") | 163 | .query("workspaces") |
| 140 | .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId)) | 164 | .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId)) |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { components } from "../../convex/_generated/api"; | 1 | import { components } from "../../convex/_generated/api"; |
| 2 | import { z } from "zod"; | ||
| 2 | import type { RunQueryCtx } from "./types"; | 3 | import type { RunQueryCtx } from "./types"; |
| 3 | 4 | ||
| 4 | type WorkosProfile = { | 5 | type WorkosProfile = { |
| @@ -8,17 +9,20 @@ type WorkosProfile = { · 3 unmodified lines | |||
| 8 | profilePictureUrl?: string | null; | 9 | profilePictureUrl?: string | null; |
| 9 | }; | 10 | }; |
| 10 | 11 | ||
| 12 | const nonEmptyTrimmedStringSchema = z.string().transform((value) => value.trim()).refine((value) => value.length > 0); | ||
| 13 | |||
| 11 | function deriveFallbackUserLabel(workosUserId: string): string { | 14 | function deriveFallbackUserLabel(workosUserId: string): string { |
| 12 | return `User ${workosUserId.slice(-6)}`; | 15 | return `User ${workosUserId.slice(-6)}`; |
| 13 | } | 16 | } |
| 14 | 17 | ||
| 15 | function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { | 18 | function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { |
| 16 | for (const key of keys) { | 19 | for (const key of keys) { |
| 17 | const value = identity[key]; | 20 | const parsedValue = nonEmptyTrimmedStringSchema.safeParse(identity[key]); |
| 18 | if (typeof value === "string" && value.trim().length > 0) { | 21 | if (parsedValue.success) { |
| 19 | return value.trim(); | 22 | return parsedValue.data; |
| 20 | } | 23 | } |
| 21 | } | 24 | } |
| 25 | |||
| 22 | return undefined; | 26 | return undefined; |
| 23 | } | 27 | } |
| 24 | 28 | ||
| @@ -1,16 +1,43 @@ | |||
| 1 | import { WorkOS } from "@workos-inc/node"; | 1 | import { Result } from "better-result"; |
| 2 | import { z } from "zod"; | ||
| 2 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; | 3 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| 3 | import type { ActionCtx } from "../../convex/_generated/server"; | 4 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 5 | import { | ||
| 6 | createWorkosClient, | ||
| 7 | extractWorkosVaultObjectId, | ||
| 8 | withWorkosVaultRetryResult, | ||
| 9 | } from "../../../core/src/credentials/workos-vault"; | ||
| 4 | import { | 10 | import { |
| 5 | assertMatchesCanonicalActorId, | 11 | assertMatchesCanonicalActorId, |
| 6 | canonicalActorIdForWorkspaceAccess, | 12 | canonicalActorIdForWorkspaceAccess, |
| 7 | } from "../auth/actor_identity"; | 13 | } from "../auth/actor_identity"; |
| 8 | import { asRecord } from "../lib/object"; | ||
| 9 | 14 | ||
| 10 | type Internal = typeof import("../../convex/_generated/api").internal; | 15 | type Internal = typeof import("../../convex/_generated/api").internal; |
| 11 | 16 | ||
| 12 | type SecretBackend = "local-convex" | "workos-vault"; | 17 | type SecretBackend = "local-convex" | "workos-vault"; |
| 13 | 18 | ||
| 19 | const recordSchema = z.record(z.unknown()); | ||
| 20 | |||
| 21 | const secretPayloadSchema = z.object({ | ||
| 22 | __headers: z.record(z.unknown()).optional(), | ||
| 23 | }).catchall(z.unknown()); | ||
| 24 | |||
| 25 | const listedCredentialSchema = z.object({ | ||
| 26 | id: z.string().optional(), | ||
| 27 | bindingId: z.string().optional(), | ||
| 28 | ownerScopeType: z.enum(["organization", "workspace"]).optional(), | ||
| 29 | scope: z.enum(["workspace", "actor"]).optional(), | ||
| 30 | actorId: z.string().optional(), | ||
| 31 | secretJson: z.record(z.unknown()).optional(), | ||
| 32 | }); | ||
| 33 | |||
| 34 | type ListedCredential = z.infer<typeof listedCredentialSchema>; | ||
| 35 | |||
| 36 | function toRecordValue(value: unknown): Record<string, unknown> { | ||
| 37 | const parsed = recordSchema.safeParse(value); | ||
| 38 | return parsed.success ? parsed.data : {}; | ||
| 39 | } | ||
| 40 | |||
| 14 | function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string { | 41 | function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string { |
| 15 | if (scope !== "actor") return ""; | 42 | if (scope !== "actor") return ""; |
| 16 | if (typeof actorId !== "string") return ""; | 43 | if (typeof actorId !== "string") return ""; |
| @@ -28,34 +55,40 @@ function configuredSecretBackend(): SecretBackend { · 11 unmodified lines | |||
| 28 | return process.env.WORKOS_API_KEY?.trim() ? "workos-vault" : "local-convex"; | 55 | return process.env.WORKOS_API_KEY?.trim() ? "workos-vault" : "local-convex"; |
| 29 | } | 56 | } |
| 30 | 57 | ||
| 31 | function extractObjectId(secretJson: Record<string, unknown>): string | null { | ||
| 32 | const candidate = | ||
| 33 | (typeof secretJson.objectId === "string" ? secretJson.objectId : "") || | ||
| 34 | (typeof secretJson.id === "string" ? secretJson.id : ""); | ||
| 35 | const trimmed = candidate.trim(); | ||
| 36 | return trimmed.length > 0 ? trimmed : null; | ||
| 37 | } | ||
| 38 | |||
| 39 | function extractHeaderOverrides(secretJson: Record<string, unknown>): { | 58 | function extractHeaderOverrides(secretJson: Record<string, unknown>): { |
| 40 | cleanSecret: Record<string, unknown>; | 59 | cleanSecret: Record<string, unknown>; |
| 41 | overridesJson: Record<string, unknown>; | 60 | overridesJson: Record<string, unknown>; |
| 42 | } { | 61 | } { |
| 43 | const rawHeaders = asRecord(secretJson.__headers); | 62 | const parsedSecret = secretPayloadSchema.safeParse(secretJson); |
| 63 | const normalizedSecret = parsedSecret.success ? parsedSecret.data : {}; | ||
| 64 | const rawHeaders = toRecordValue(normalizedSecret.__headers); | ||
| 44 | const headers: Record<string, string> = {}; | 65 | const headers: Record<string, string> = {}; |
| 45 | for (const [name, value] of Object.entries(rawHeaders)) { | 66 | for (const [name, value] of Object.entries(rawHeaders)) { |
| 46 | if (!name) continue; | 67 | const headerName = name.trim(); |
| 68 | if (!headerName) continue; | ||
| 47 | const text = typeof value === "string" ? value.trim() : String(value ?? "").trim(); | 69 | const text = typeof value === "string" ? value.trim() : String(value ?? "").trim(); |
| 48 | if (!text) continue; | 70 | if (!text) continue; |
| 49 | headers[name.trim()] = text; | 71 | headers[headerName] = text; |
| 50 | } | 72 | } |
| 51 | 73 | ||
| 52 | const { __headers: _headers, ...rest } = secretJson; | 74 | const { __headers: _headers, ...rest } = normalizedSecret; |
| 53 | return { | 75 | return { |
| 54 | cleanSecret: asRecord(rest), | 76 | cleanSecret: toRecordValue(rest), |
| 55 | overridesJson: Object.keys(headers).length > 0 ? { headers } : {}, | 77 | overridesJson: Object.keys(headers).length > 0 ? { headers } : {}, |
| 56 | }; | 78 | }; |
| 57 | } | 79 | } |
| 58 | 80 | ||
| 81 | function parseListedCredentials(value: unknown): ListedCredential[] { | ||
| 82 | if (!Array.isArray(value)) { | ||
| 83 | return []; | ||
| 84 | } | ||
| 85 | |||
| 86 | return value.flatMap((entry) => { | ||
| 87 | const parsed = listedCredentialSchema.safeParse(entry); | ||
| 88 | return parsed.success ? [parsed.data] : []; | ||
| 89 | }); | ||
| 90 | } | ||
| 91 | |||
| 59 | function buildVaultObjectName(args: { | 92 | function buildVaultObjectName(args: { |
| 60 | workspaceId: string; | 93 | workspaceId: string; |
| 61 | sourceKey: string; | 94 | sourceKey: string; |
| @@ -71,56 +104,6 @@ function buildVaultObjectName(args: { · 9 unmodified lines | |||
| 71 | return `executor-conn-${args.workspaceId.slice(0, 24)}-${sourceSegment}-${actorSegment.slice(0, 24)}-${crypto.randomUUID().slice(0, 8)}`; | 104 | return `executor-conn-${args.workspaceId.slice(0, 24)}-${sourceSegment}-${actorSegment.slice(0, 24)}-${crypto.randomUUID().slice(0, 8)}`; |
| 72 | } | 105 | } |
| 73 | 106 | ||
| 74 | function workosClient(): WorkOS { | ||
| 75 | const key = process.env.WORKOS_API_KEY?.trim(); | ||
| 76 | if (!key) { | ||
| 77 | throw new Error("Encrypted storage requires WORKOS_API_KEY"); | ||
| 78 | } | ||
| 79 | return new WorkOS(key); | ||
| 80 | } | ||
| 81 | |||
| 82 | function isRetryableVaultError(error: unknown): boolean { | ||
| 83 | if (!(error instanceof Error)) { | ||
| 84 | return false; | ||
| 85 | } | ||
| 86 | |||
| 87 | const message = error.message.toLowerCase(); | ||
| 88 | return ( | ||
| 89 | message.includes("not yet ready") || | ||
| 90 | message.includes("can be retried") || | ||
| 91 | (message.includes("kek") && message.includes("ready")) | ||
| 92 | ); | ||
| 93 | } | ||
| 94 | |||
| 95 | async function sleep(ms: number): Promise<void> { | ||
| 96 | await new Promise((resolve) => setTimeout(resolve, ms)); | ||
| 97 | } | ||
| 98 | |||
| 99 | async function withVaultRetry<T>(operation: () => Promise<T>): Promise<T> { | ||
| 100 | const maxAttempts = 10; | ||
| 101 | const maxDelayMs = 10_000; | ||
| 102 | let delayMs = 500; | ||
| 103 | |||
| 104 | for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { | ||
| 105 | try { | ||
| 106 | return await operation(); | ||
| 107 | } catch (error) { | ||
| 108 | if (!isRetryableVaultError(error)) { | ||
| 109 | throw error; | ||
| 110 | } | ||
| 111 | if (attempt === maxAttempts) { | ||
| 112 | throw new Error( | ||
| 113 | "Encrypted storage is still initializing in WorkOS. Please wait about 60 seconds and retry.", | ||
| 114 | ); | ||
| 115 | } | ||
| 116 | await sleep(delayMs); | ||
| 117 | delayMs = Math.min(delayMs * 2, maxDelayMs); | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | throw new Error("Unreachable retry state"); | ||
| 122 | } | ||
| 123 | |||
| 124 | async function upsertVaultObject(args: { | 107 | async function upsertVaultObject(args: { |
| 125 | workspaceId: string; | 108 | workspaceId: string; |
| 126 | sourceKey: string; | 109 | sourceKey: string; |
| @@ -128,22 +111,36 @@ async function upsertVaultObject(args: { · 1 unmodified line | |||
| 128 | actorId: string; | 111 | actorId: string; |
| 129 | existingObjectId: string | null; | 112 | existingObjectId: string | null; |
| 130 | payload: Record<string, unknown>; | 113 | payload: Record<string, unknown>; |
| 131 | }): Promise<string> { | 114 | }): Promise<Result<string, Error>> { |
| 132 | const workos = workosClient(); | 115 | const workosResult = createWorkosClient(); |
| 116 | if (workosResult.isErr()) { | ||
| 117 | return Result.err(workosResult.error); | ||
| 118 | } | ||
| 119 | |||
| 120 | const workos = workosResult.value; | ||
| 133 | const value = JSON.stringify(args.payload); | 121 | const value = JSON.stringify(args.payload); |
| 134 | 122 | ||
| 135 | if (args.existingObjectId) { | 123 | if (args.existingObjectId) { |
| 136 | const objectId: string = args.existingObjectId; | 124 | const objectId: string = args.existingObjectId; |
| 137 | const updated = await withVaultRetry(async () => { | 125 | const updatedResult = await withWorkosVaultRetryResult(async () => { |
| 138 | return await workos.vault.updateObject({ | 126 | return await workos.vault.updateObject({ |
| 139 | id: objectId, | 127 | id: objectId, |
| 140 | value, | 128 | value, |
| 141 | }); | 129 | }); |
| 130 | }, { | ||
| 131 | maxAttempts: 10, | ||
| 132 | initialDelayMs: 500, | ||
| 133 | maxDelayMs: 10_000, | ||
| 134 | exhaustionErrorMessage: "Encrypted storage is still initializing in WorkOS. Please wait about 60 seconds and retry.", | ||
| 142 | }); | 135 | }); |
| 143 | return updated.id; | 136 | if (updatedResult.isErr()) { |
| 137 | return Result.err(updatedResult.error); | ||
| 138 | } | ||
| 139 | |||
| 140 | return Result.ok(updatedResult.value.id); | ||
| 144 | } | 141 | } |
| 145 | 142 | ||
| 146 | const created = await withVaultRetry(async () => { | 143 | const createdResult = await withWorkosVaultRetryResult(async () => { |
| 147 | return await workos.vault.createObject({ | 144 | return await workos.vault.createObject({ |
| 148 | name: buildVaultObjectName(args), | 145 | name: buildVaultObjectName(args), |
| 149 | value, | 146 | value, |
| @@ -151,9 +148,17 @@ async function upsertVaultObject(args: { · 1 unmodified line | |||
| 151 | workspace_id: args.workspaceId, | 148 | workspace_id: args.workspaceId, |
| 152 | }, | 149 | }, |
| 153 | }); | 150 | }); |
| 151 | }, { | ||
| 152 | maxAttempts: 10, | ||
| 153 | initialDelayMs: 500, | ||
| 154 | maxDelayMs: 10_000, | ||
| 155 | exhaustionErrorMessage: "Encrypted storage is still initializing in WorkOS. Please wait about 60 seconds and retry.", | ||
| 154 | }); | 156 | }); |
| 157 | if (createdResult.isErr()) { | ||
| 158 | return Result.err(createdResult.error); | ||
| 159 | } | ||
| 155 | 160 | ||
| 156 | return created.id; | 161 | return Result.ok(createdResult.value.id); |
| 157 | } | 162 | } |
| 158 | 163 | ||
| 159 | export async function upsertCredentialHandler( | 164 | export async function upsertCredentialHandler( |
| @@ -163,6 +168,7 @@ export async function upsertCredentialHandler( · 3 unmodified lines | |||
| 163 | id?: string; | 168 | id?: string; |
| 164 | workspaceId: Id<"workspaces">; | 169 | workspaceId: Id<"workspaces">; |
| 165 | sessionId?: string; | 170 | sessionId?: string; |
| 171 | ownerScopeType?: "organization" | "workspace"; | ||
| 166 | sourceKey: string; | 172 | sourceKey: string; |
| 167 | scope: "workspace" | "actor"; | 173 | scope: "workspace" | "actor"; |
| 168 | actorId?: string; | 174 | actorId?: string; |
| @@ -180,7 +186,8 @@ export async function upsertCredentialHandler( · 11 unmodified lines | |||
| 180 | } | 186 | } |
| 181 | 187 | ||
| 182 | const actorId = normalizedActorId(args.scope, args.actorId ?? canonicalActorId); | 188 | const actorId = normalizedActorId(args.scope, args.actorId ?? canonicalActorId); |
| 183 | const rawSubmittedSecret = asRecord(args.secretJson); | 189 | const ownerScopeType = args.ownerScopeType ?? "workspace"; |
| 190 | const rawSubmittedSecret = toRecordValue(args.secretJson); | ||
| 184 | const { cleanSecret: submittedSecret, overridesJson } = extractHeaderOverrides(rawSubmittedSecret); | 191 | const { cleanSecret: submittedSecret, overridesJson } = extractHeaderOverrides(rawSubmittedSecret); |
| 185 | 192 | ||
| 186 | const existingBinding = await ctx.runQuery(internal.database.resolveCredential, { | 193 | const existingBinding = await ctx.runQuery(internal.database.resolveCredential, { |
| @@ -190,34 +197,36 @@ export async function upsertCredentialHandler( · 3 unmodified lines | |||
| 190 | ...(args.scope === "actor" ? { actorId } : {}), | 197 | ...(args.scope === "actor" ? { actorId } : {}), |
| 191 | }); | 198 | }); |
| 192 | 199 | ||
| 193 | const allCredentials: Array<Record<string, unknown>> = await ctx.runQuery(internal.database.listCredentials, { | 200 | const allCredentialsRaw = await ctx.runQuery(internal.database.listCredentials, { |
| 194 | workspaceId: args.workspaceId, | 201 | workspaceId: args.workspaceId, |
| 195 | }); | 202 | }); |
| 203 | const allCredentials = parseListedCredentials(allCredentialsRaw); | ||
| 196 | const requestedId = args.id?.trim(); | 204 | const requestedId = args.id?.trim(); |
| 197 | const existingConnection = requestedId | 205 | const existingConnection = requestedId |
| 198 | ? allCredentials.find((credential) => { | 206 | ? allCredentials.find((credential) => { |
| 199 | const id = String(credential.id ?? ""); | 207 | const id = (credential.id ?? "").trim(); |
| 200 | const bindingId = String(credential.bindingId ?? ""); | 208 | const bindingId = (credential.bindingId ?? "").trim(); |
| 201 | if (id !== requestedId && bindingId !== requestedId) return false; | 209 | if (id !== requestedId && bindingId !== requestedId) return false; |
| 202 | if (String(credential.scope ?? "") !== args.scope) return false; | 210 | if ((credential.ownerScopeType ?? "workspace") !== ownerScopeType) return false; |
| 211 | if (credential.scope !== args.scope) return false; | ||
| 203 | if (args.scope === "actor") { | 212 | if (args.scope === "actor") { |
| 204 | return String(credential.actorId ?? "") === actorId; | 213 | return (credential.actorId ?? "").trim() === actorId; |
| 205 | } | 214 | } |
| 206 | return true; | 215 | return true; |
| 207 | }) ?? allCredentials.find((credential) => { | 216 | }) ?? allCredentials.find((credential) => { |
| 208 | const id = String(credential.id ?? ""); | 217 | const id = (credential.id ?? "").trim(); |
| 209 | const bindingId = String(credential.bindingId ?? ""); | 218 | const bindingId = (credential.bindingId ?? "").trim(); |
| 210 | return id === requestedId || bindingId === requestedId; | 219 | return id === requestedId || bindingId === requestedId; |
| 211 | }) | 220 | }) |
| 212 | : null; | 221 | : null; |
| 213 | const connectionId = String(existingConnection?.id ?? requestedId ?? "").trim() || undefined; | 222 | const connectionId = (existingConnection?.id ?? requestedId ?? "").trim() || undefined; |
| 214 | 223 | ||
| 215 | const backend = configuredSecretBackend(); | 224 | const backend = configuredSecretBackend(); |
| 216 | 225 | ||
| 217 | if (backend === "local-convex") { | 226 | if (backend === "local-convex") { |
| 218 | const finalSecret = Object.keys(submittedSecret).length > 0 | 227 | const finalSecret = Object.keys(submittedSecret).length > 0 |
| 219 | ? submittedSecret | 228 | ? submittedSecret |
| 220 | : asRecord(existingConnection?.secretJson ?? existingBinding?.secretJson); | 229 | : toRecordValue(existingConnection?.secretJson ?? existingBinding?.secretJson); |
| 221 | if (Object.keys(finalSecret).length === 0) { | 230 | if (Object.keys(finalSecret).length === 0) { |
| 222 | throw new Error("Credential values are required"); | 231 | throw new Error("Credential values are required"); |
| 223 | } | 232 | } |
| @@ -225,6 +234,7 @@ export async function upsertCredentialHandler( · 1 unmodified line | |||
| 225 | return await ctx.runMutation(internal.database.upsertCredential, { | 234 | return await ctx.runMutation(internal.database.upsertCredential, { |
| 226 | id: connectionId, | 235 | id: connectionId, |
| 227 | workspaceId: args.workspaceId, | 236 | workspaceId: args.workspaceId, |
| 237 | ownerScopeType, | ||
| 228 | sourceKey: args.sourceKey, | 238 | sourceKey: args.sourceKey, |
| 229 | scope: args.scope, | 239 | scope: args.scope, |
| 230 | ...(args.scope === "actor" ? { actorId } : {}), | 240 | ...(args.scope === "actor" ? { actorId } : {}), |
| @@ -234,18 +244,18 @@ export async function upsertCredentialHandler( · 3 unmodified lines | |||
| 234 | }); | 244 | }); |
| 235 | } | 245 | } |
| 236 | 246 | ||
| 237 | const submittedObjectId = extractObjectId(submittedSecret); | 247 | const submittedObjectId = extractWorkosVaultObjectId(submittedSecret); |
| 238 | if (submittedObjectId && /^gh[opu]_/.test(submittedObjectId)) { | 248 | if (submittedObjectId && /^gh[opu]_/.test(submittedObjectId)) { |
| 239 | throw new Error("Encrypted storage value looks like a GitHub token. Paste the token in the token field."); | 249 | throw new Error("Encrypted storage value looks like a GitHub token. Paste the token in the token field."); |
| 240 | } | 250 | } |
| 241 | 251 | ||
| 242 | const existingObjectId = extractObjectId( | 252 | const existingObjectId = extractWorkosVaultObjectId( |
| 243 | asRecord(existingConnection?.secretJson ?? existingBinding?.secretJson), | 253 | toRecordValue(existingConnection?.secretJson ?? existingBinding?.secretJson), |
| 244 | ); | 254 | ); |
| 245 | 255 | ||
| 246 | let finalObjectId = submittedObjectId; | 256 | let finalObjectId = submittedObjectId; |
| 247 | if (!finalObjectId && Object.keys(submittedSecret).length > 0) { | 257 | if (!finalObjectId && Object.keys(submittedSecret).length > 0) { |
| 248 | finalObjectId = await upsertVaultObject({ | 258 | const upsertResult = await upsertVaultObject({ |
| 249 | workspaceId: args.workspaceId, | 259 | workspaceId: args.workspaceId, |
| 250 | sourceKey: args.sourceKey, | 260 | sourceKey: args.sourceKey, |
| 251 | scope: args.scope, | 261 | scope: args.scope, |
| @@ -253,6 +263,10 @@ export async function upsertCredentialHandler( · 1 unmodified line | |||
| 253 | existingObjectId, | 263 | existingObjectId, |
| 254 | payload: submittedSecret, | 264 | payload: submittedSecret, |
| 255 | }); | 265 | }); |
| 266 | if (upsertResult.isErr()) { | ||
| 267 | throw upsertResult.error; | ||
| 268 | } | ||
| 269 | finalObjectId = upsertResult.value; | ||
| 256 | } | 270 | } |
| 257 | 271 | ||
| 258 | if (!finalObjectId && existingObjectId) { | 272 | if (!finalObjectId && existingObjectId) { |
| @@ -266,6 +280,7 @@ export async function upsertCredentialHandler( · 7 unmodified lines | |||
| 266 | return await ctx.runMutation(internal.database.upsertCredential, { | 280 | return await ctx.runMutation(internal.database.upsertCredential, { |
| 267 | id: connectionId, | 281 | id: connectionId, |
| 268 | workspaceId: args.workspaceId, | 282 | workspaceId: args.workspaceId, |
| 283 | ownerScopeType, | ||
| 269 | sourceKey: args.sourceKey, | 284 | sourceKey: args.sourceKey, |
| 270 | scope: args.scope, | 285 | scope: args.scope, |
| 271 | ...(args.scope === "actor" ? { actorId } : {}), | 286 | ...(args.scope === "actor" ? { actorId } : {}), |
| @@ -1,13 +1,20 @@ | |||
| 1 | import type { Doc } from "../../convex/_generated/dataModel.d.ts"; | 1 | import type { Doc } from "../../convex/_generated/dataModel.d.ts"; |
| 2 | import { asRecord } from "../lib/object"; | 2 | import { z } from "zod"; |
| 3 | import { isRecord } from "../lib/object"; | ||
| 3 | import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants"; | 4 | import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants"; |
| 4 | 5 | ||
| 6 | const sourceAuthSchema = z.object({ | ||
| 7 | type: z.string().optional(), | ||
| 8 | mode: z.enum(["workspace", "actor"]).optional(), | ||
| 9 | header: z.string().optional(), | ||
| 10 | }); | ||
| 11 | |||
| 5 | function stableStringify(value: unknown): string { | 12 | function stableStringify(value: unknown): string { |
| 6 | if (Array.isArray(value)) { | 13 | if (Array.isArray(value)) { |
| 7 | return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; | 14 | return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; |
| 8 | } | 15 | } |
| 9 | if (value && typeof value === "object") { | 16 | if (isRecord(value)) { |
| 10 | const record = asRecord(value); | 17 | const record = value; |
| 11 | const keys = Object.keys(record).sort(); | 18 | const keys = Object.keys(record).sort(); |
| 12 | return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`; | 19 | return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`; |
| 13 | } | 20 | } |
| @@ -15,10 +22,11 @@ function stableStringify(value: unknown): string { · 1 unmodified line | |||
| 15 | } | 22 | } |
| 16 | 23 | ||
| 17 | export function normalizeSourceAuthFingerprint(value: unknown): string { | 24 | export function normalizeSourceAuthFingerprint(value: unknown): string { |
| 18 | const auth = asRecord(value); | 25 | const parsedAuth = sourceAuthSchema.safeParse(value); |
| 19 | const type = typeof auth.type === "string" ? auth.type.trim() : "none"; | 26 | const auth = parsedAuth.success ? parsedAuth.data : {}; |
| 27 | const type = (auth.type ?? "none").trim(); | ||
| 20 | const mode = auth.mode === "actor" ? "actor" : "workspace"; | 28 | const mode = auth.mode === "actor" ? "actor" : "workspace"; |
| 21 | const header = typeof auth.header === "string" ? auth.header.trim().toLowerCase() : ""; | 29 | const header = (auth.header ?? "").trim().toLowerCase(); |
| 22 | return stableStringify({ | 30 | return stableStringify({ |
| 23 | type: type || "none", | 31 | type: type || "none", |
| 24 | mode, | 32 | mode, |
| @@ -43,13 +51,14 @@ export function computeSourceSpecHash(type: "mcp" | "openapi" | "graphql", confi · 18 unmodified lines | |||
| 43 | } | 51 | } |
| 44 | 52 | ||
| 45 | export function mapTask(doc: Doc<"tasks">) { | 53 | export function mapTask(doc: Doc<"tasks">) { |
| 54 | const metadata = isRecord(doc.metadata) ? doc.metadata : {}; | ||
| 46 | return { | 55 | return { |
| 47 | id: doc.taskId, | 56 | id: doc.taskId, |
| 48 | code: doc.code, | 57 | code: doc.code, |
| 49 | runtimeId: doc.runtimeId, | 58 | runtimeId: doc.runtimeId, |
| 50 | status: doc.status, | 59 | status: doc.status, |
| 51 | timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TASK_TIMEOUT_MS, | 60 | timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TASK_TIMEOUT_MS, |
| 52 | metadata: asRecord(doc.metadata), | 61 | metadata, |
| 53 | workspaceId: doc.workspaceId, | 62 | workspaceId: doc.workspaceId, |
| 54 | actorId: doc.actorId, | 63 | actorId: doc.actorId, |
| 55 | clientId: doc.clientId, | 64 | clientId: doc.clientId, |
| @@ -92,30 +101,64 @@ export function mapToolCall(doc: Doc<"toolCalls">) { · 36 unmodified lines | |||
| 92 | } | 101 | } |
| 93 | 102 | ||
| 94 | export function mapPolicy(doc: Doc<"accessPolicies">) { | 103 | export function mapPolicy(doc: Doc<"accessPolicies">) { |
| 104 | const legacyDecision = typeof Reflect.get(doc as object, "decision") === "string" | ||
| 105 | ? String(Reflect.get(doc as object, "decision")) | ||
| 106 | : undefined; | ||
| 107 | const resourcePattern = doc.resourcePattern | ||
| 108 | || (typeof Reflect.get(doc as object, "toolPathPattern") === "string" | ||
| 109 | ? String(Reflect.get(doc as object, "toolPathPattern")) | ||
| 110 | : "*"); | ||
| 111 | const scopeType = doc.scopeType ?? (doc.workspaceId ? "workspace" : "organization"); | ||
| 112 | const targetActorId = doc.targetActorId | ||
| 113 | ?? (typeof Reflect.get(doc as object, "actorId") === "string" | ||
| 114 | ? String(Reflect.get(doc as object, "actorId")) | ||
| 115 | : undefined); | ||
| 116 | const effect = doc.effect ?? (legacyDecision === "deny" ? "deny" : "allow"); | ||
| 117 | const approvalMode = doc.approvalMode | ||
| 118 | ?? (legacyDecision === "require_approval" ? "required" : legacyDecision === "allow" ? "auto" : "inherit"); | ||
| 119 | const decision = effect === "deny" | ||
| 120 | ? "deny" | ||
| 121 | : approvalMode === "required" | ||
| 122 | ? "require_approval" | ||
| 123 | : "allow"; | ||
| 124 | |||
| 95 | return { | 125 | return { |
| 96 | id: doc.policyId, | 126 | id: doc.policyId, |
| 127 | scopeType, | ||
| 128 | organizationId: doc.organizationId, | ||
| 97 | workspaceId: doc.workspaceId, | 129 | workspaceId: doc.workspaceId, |
| 98 | actorId: doc.actorId, | 130 | targetActorId, |
| 99 | clientId: doc.clientId, | 131 | clientId: doc.clientId, |
| 100 | toolPathPattern: doc.toolPathPattern, | 132 | resourceType: doc.resourceType ?? "tool_path", |
| 101 | decision: doc.decision, | 133 | resourcePattern, |
| 134 | matchType: doc.matchType ?? "glob", | ||
| 135 | effect, | ||
| 136 | approvalMode, | ||
| 102 | priority: doc.priority, | 137 | priority: doc.priority, |
| 103 | createdAt: doc.createdAt, | 138 | createdAt: doc.createdAt, |
| 104 | updatedAt: doc.updatedAt, | 139 | updatedAt: doc.updatedAt, |
| 140 | // Legacy aliases retained during transition. | ||
| 141 | actorId: targetActorId, | ||
| 142 | toolPathPattern: resourcePattern, | ||
| 143 | decision, | ||
| 105 | }; | 144 | }; |
| 106 | } | 145 | } |
| 107 | 146 | ||
| 108 | export function mapCredential(doc: Doc<"sourceCredentials">) { | 147 | export function mapCredential(doc: Doc<"sourceCredentials">) { |
| 148 | const secretJson = isRecord(doc.secretJson) ? doc.secretJson : {}; | ||
| 149 | const overridesJson = isRecord(doc.overridesJson) ? doc.overridesJson : {}; | ||
| 109 | return { | 150 | return { |
| 110 | id: doc.credentialId, | 151 | id: doc.credentialId, |
| 111 | bindingId: doc.bindingId, | 152 | bindingId: doc.bindingId, |
| 153 | ownerScopeType: doc.ownerScopeType ?? (doc.workspaceId ? "workspace" : "organization"), | ||
| 154 | organizationId: doc.organizationId, | ||
| 112 | workspaceId: doc.workspaceId, | 155 | workspaceId: doc.workspaceId, |
| 113 | sourceKey: doc.sourceKey, | 156 | sourceKey: doc.sourceKey, |
| 114 | scope: doc.scope, | 157 | scope: doc.scope, |
| 115 | actorId: doc.actorId || undefined, | 158 | actorId: doc.actorId || undefined, |
| 116 | provider: doc.provider, | 159 | provider: doc.provider, |
| 117 | secretJson: asRecord(doc.secretJson), | 160 | secretJson, |
| 118 | overridesJson: asRecord(doc.overridesJson), | 161 | overridesJson, |
| 119 | boundAuthFingerprint: doc.boundAuthFingerprint, | 162 | boundAuthFingerprint: doc.boundAuthFingerprint, |
| 120 | createdAt: doc.createdAt, | 163 | createdAt: doc.createdAt, |
| 121 | updatedAt: doc.updatedAt, | 164 | updatedAt: doc.updatedAt, |
| @@ -123,12 +166,15 @@ export function mapCredential(doc: Doc<"sourceCredentials">) { · 1 unmodified line | |||
| 123 | } | 166 | } |
| 124 | 167 | ||
| 125 | export function mapSource(doc: Doc<"toolSources">) { | 168 | export function mapSource(doc: Doc<"toolSources">) { |
| 169 | const config = isRecord(doc.config) ? doc.config : {}; | ||
| 126 | return { | 170 | return { |
| 127 | id: doc.sourceId, | 171 | id: doc.sourceId, |
| 172 | ownerScopeType: doc.ownerScopeType ?? (doc.workspaceId ? "workspace" : "organization"), | ||
| 173 | organizationId: doc.organizationId, | ||
| 128 | workspaceId: doc.workspaceId, | 174 | workspaceId: doc.workspaceId, |
| 129 | name: doc.name, | 175 | name: doc.name, |
| 130 | type: doc.type, | 176 | type: doc.type, |
| 131 | config: asRecord(doc.config), | 177 | config, |
| 132 | specHash: doc.specHash, | 178 | specHash: doc.specHash, |
| 133 | authFingerprint: doc.authFingerprint, | 179 | authFingerprint: doc.authFingerprint, |
| 134 | enabled: Boolean(doc.enabled), | 180 | enabled: Boolean(doc.enabled), |
| @@ -1,7 +1,11 @@ | |||
| 1 | import type { Doc } from "../../convex/_generated/dataModel.d.ts"; | 1 | import type { Doc } from "../../convex/_generated/dataModel.d.ts"; |
| 2 | import type { QueryCtx } from "../../convex/_generated/server"; | 2 | import type { QueryCtx } from "../../convex/_generated/server"; |
| 3 | import { z } from "zod"; | ||
| 3 | import { normalizeSourceAuthFingerprint } from "./mappers"; | 4 | import { normalizeSourceAuthFingerprint } from "./mappers"; |
| 4 | import { asRecord } from "../lib/object"; | 5 | |
| 6 | const toolSourceConfigSchema = z.object({ | ||
| 7 | auth: z.unknown().optional(), | ||
| 8 | }); | ||
| 5 | 9 | ||
| 6 | export function mapAnonymousContext(doc: Doc<"anonymousSessions">) { | 10 | export function mapAnonymousContext(doc: Doc<"anonymousSessions">) { |
| 7 | return { | 11 | return { |
| @@ -69,9 +73,20 @@ export async function computeBoundAuthFingerprint( · 61 unmodified lines | |||
| 69 | .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) | 73 | .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) |
| 70 | .unique(); | 74 | .unique(); |
| 71 | 75 | ||
| 72 | if (!source || source.workspaceId !== workspaceId) { | 76 | const workspace = await ctx.db.get(workspaceId); |
| 77 | if (!source || !workspace) { | ||
| 78 | return normalizeSourceAuthFingerprint({ type: "none" }); | ||
| 79 | } | ||
| 80 | |||
| 81 | if (source.organizationId && source.organizationId !== workspace.organizationId) { | ||
| 82 | return normalizeSourceAuthFingerprint({ type: "none" }); | ||
| 83 | } | ||
| 84 | |||
| 85 | const workspaceOwned = source.ownerScopeType === "workspace" || (!source.ownerScopeType && Boolean(source.workspaceId)); | ||
| 86 | if (workspaceOwned && source.workspaceId !== workspaceId) { | ||
| 73 | return normalizeSourceAuthFingerprint({ type: "none" }); | 87 | return normalizeSourceAuthFingerprint({ type: "none" }); |
| 74 | } | 88 | } |
| 75 | 89 | ||
| 76 | return normalizeSourceAuthFingerprint(asRecord(source.config).auth); | 90 | const parsedConfig = toolSourceConfigSchema.safeParse(source.config); |
| 91 | return normalizeSourceAuthFingerprint(parsedConfig.success ? parsedConfig.data.auth : undefined); | ||
| 77 | } | 92 | } |
| @@ -7,7 +7,6 @@ import type { · 6 unmodified lines | |||
| 7 | OpenApiToolSourceConfig, | 7 | OpenApiToolSourceConfig, |
| 8 | } from "../../../core/src/tool/source-types"; | 8 | } from "../../../core/src/tool/source-types"; |
| 9 | import type { ToolApprovalMode } from "../../../core/src/types"; | 9 | import type { ToolApprovalMode } from "../../../core/src/types"; |
| 10 | import { asRecord } from "../lib/object"; | ||
| 11 | 10 | ||
| 12 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; | 11 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; |
| 13 | 12 | ||
| @@ -39,6 +38,8 @@ const overrideEntrySchema = z.object({ · 25 unmodified lines | |||
| 39 | }); | 38 | }); |
| 40 | const unknownRecordSchema = z.record(z.unknown()); | 39 | const unknownRecordSchema = z.record(z.unknown()); |
| 41 | const stringMapSchema = z.record(z.string()); | 40 | const stringMapSchema = z.record(z.string()); |
| 41 | const trimmedStringSchema = z.string().transform((value) => value.trim()); | ||
| 42 | const nonEmptyTrimmedStringSchema = trimmedStringSchema.refine((value) => value.length > 0); | ||
| 42 | const basicAuthSchema = z.object({ | 43 | const basicAuthSchema = z.object({ |
| 43 | mode: authModeSchema.optional(), | 44 | mode: authModeSchema.optional(), |
| 44 | username: z.string().optional(), | 45 | username: z.string().optional(), |
| @@ -55,20 +56,24 @@ const apiKeyAuthSchema = z.object({ · 10 unmodified lines | |||
| 55 | }); | 56 | }); |
| 56 | 57 | ||
| 57 | function optionalTrimmedString(value: unknown): string | undefined { | 58 | function optionalTrimmedString(value: unknown): string | undefined { |
| 58 | if (typeof value !== "string") return undefined; | 59 | const parsed = trimmedStringSchema.safeParse(value); |
| 59 | const trimmed = value.trim(); | 60 | if (!parsed.success || parsed.data.length === 0) { |
| 60 | return trimmed.length > 0 ? trimmed : undefined; | 61 | return undefined; |
| 62 | } | ||
| 63 | |||
| 64 | return parsed.data; | ||
| 61 | } | 65 | } |
| 62 | 66 | ||
| 63 | function requiredTrimmedString( | 67 | function requiredTrimmedString( |
| 64 | value: unknown, | 68 | value: unknown, |
| 65 | fieldName: string, | 69 | fieldName: string, |
| 66 | ): Result<string, Error> { | 70 | ): Result<string, Error> { |
| 67 | const trimmed = optionalTrimmedString(value); | 71 | const parsed = nonEmptyTrimmedStringSchema.safeParse(value); |
| 68 | if (!trimmed) { | 72 | if (!parsed.success) { |
| 69 | return Result.err(new Error(`Tool source ${fieldName} is required`)); | 73 | return Result.err(new Error(`Tool source ${fieldName} is required`)); |
| 70 | } | 74 | } |
| 71 | return Result.ok(trimmed); | 75 | |
| 76 | return Result.ok(parsed.data); | ||
| 72 | } | 77 | } |
| 73 | 78 | ||
| 74 | function normalizeStringMap( | 79 | function normalizeStringMap( |
| @@ -151,7 +156,12 @@ function normalizeOverrides( · 76 unmodified lines | |||
| 151 | function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> { | 156 | function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> { |
| 152 | if (value === undefined) return Result.ok(undefined); | 157 | if (value === undefined) return Result.ok(undefined); |
| 153 | 158 | ||
| 154 | const auth = asRecord(value); | 159 | const authRecord = unknownRecordSchema.safeParse(value); |
| 160 | if (!authRecord.success) { | ||
| 161 | return Result.err(new Error("Tool source auth.type is required when auth is provided")); | ||
| 162 | } | ||
| 163 | |||
| 164 | const auth = authRecord.data; | ||
| 155 | const authType = optionalTrimmedString(auth.type); | 165 | const authType = optionalTrimmedString(auth.type); |
| 156 | if (!authType) { | 166 | if (!authType) { |
| 157 | return Result.err(new Error("Tool source auth.type is required when auth is provided")); | 167 | return Result.err(new Error("Tool source auth.type is required when auth is provided")); |
| @@ -217,13 +227,9 @@ function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> { · 59 unmodified lines | |||
| 217 | function normalizeSpec( | 227 | function normalizeSpec( |
| 218 | value: unknown, | 228 | value: unknown, |
| 219 | ): Result<string | Record<string, unknown>, Error> { | 229 | ): Result<string | Record<string, unknown>, Error> { |
| 220 | const stringSpec = z.string().safeParse(value); | 230 | const stringSpec = nonEmptyTrimmedStringSchema.safeParse(value); |
| 221 | if (stringSpec.success) { | 231 | if (stringSpec.success) { |
| 222 | const trimmed = stringSpec.data.trim(); | 232 | return Result.ok(stringSpec.data); |
| 223 | if (!trimmed) { | ||
| 224 | return Result.err(new Error("Tool source spec is required")); | ||
| 225 | } | ||
| 226 | return Result.ok(trimmed); | ||
| 227 | } | 233 | } |
| 228 | 234 | ||
| 229 | const specObjectResult = unknownRecordSchema.safeParse(value); | 235 | const specObjectResult = unknownRecordSchema.safeParse(value); |
| @@ -18,6 +18,11 @@ export const terminalToolCallStatusValidator = v.union( · 17 unmodified lines | |||
| 18 | ); | 18 | ); |
| 19 | 19 | ||
| 20 | export const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | 20 | export const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 21 | export const policyScopeTypeValidator = v.union(v.literal("organization"), v.literal("workspace")); | ||
| 22 | export const policyMatchTypeValidator = v.union(v.literal("glob"), v.literal("exact")); | ||
| 23 | export const policyEffectValidator = v.union(v.literal("allow"), v.literal("deny")); | ||
| 24 | export const policyApprovalModeValidator = v.union(v.literal("inherit"), v.literal("auto"), v.literal("required")); | ||
| 25 | export const ownerScopeTypeValidator = v.union(v.literal("organization"), v.literal("workspace")); | ||
| 21 | 26 | ||
| 22 | export const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | 27 | export const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); |
| 23 | 28 | ||
| @@ -1,13 +1,12 @@ | |||
| 1 | export function asRecord(value: unknown): Record<string, unknown> { | 1 | import { z } from "zod"; |
| 2 | if (value && typeof value === "object" && !Array.isArray(value)) { | 2 | |
| 3 | return value as Record<string, unknown>; | 3 | const recordSchema = z.record(z.unknown()); |
| 4 | } | 4 | |
| 5 | return {}; | 5 | export function isRecord(value: unknown): value is Record<string, unknown> { |
| 6 | return recordSchema.safeParse(value).success; | ||
| 6 | } | 7 | } |
| 7 | 8 | ||
| 8 | export function asPayload(value: unknown): Record<string, unknown> { | 9 | export function toRecord(value: unknown): Record<string, unknown> { |
| 9 | if (value && typeof value === "object") { | 10 | const parsed = recordSchema.safeParse(value); |
| 10 | return value as Record<string, unknown>; | 11 | return parsed.success ? parsed.data : {}; |
| 11 | } | ||
| 12 | return { value }; | ||
| 13 | } | 12 | } |
| @@ -7,21 +7,55 @@ import type { · 6 unmodified lines | |||
| 7 | ToolDefinition, | 7 | ToolDefinition, |
| 8 | } from "../../../core/src/types"; | 8 | } from "../../../core/src/types"; |
| 9 | 9 | ||
| 10 | function matchesToolPath(pattern: string, toolPath: string): boolean { | 10 | function matchesToolPath(pattern: string, toolPath: string, matchType: "glob" | "exact" = "glob"): boolean { |
| 11 | if (matchType === "exact") { | ||
| 12 | return pattern === toolPath; | ||
| 13 | } | ||
| 14 | |||
| 11 | const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); | 15 | const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); |
| 12 | const regex = new RegExp(`^${escaped}$`); | 16 | const regex = new RegExp(`^${escaped}$`); |
| 13 | return regex.test(toolPath); | 17 | return regex.test(toolPath); |
| 14 | } | 18 | } |
| 15 | 19 | ||
| 16 | function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientId?: string): number { | 20 | function policySpecificity( |
| 21 | policy: AccessPolicyRecord, | ||
| 22 | context: { workspaceId: string; actorId?: string; clientId?: string }, | ||
| 23 | ): number { | ||
| 24 | const scopeType = policy.scopeType ?? (policy.workspaceId ? "workspace" : "organization"); | ||
| 25 | const targetActorId = policy.targetActorId ?? policy.actorId; | ||
| 26 | const resourcePattern = policy.resourcePattern ?? policy.toolPathPattern ?? "*"; | ||
| 27 | const matchType = policy.matchType ?? "glob"; | ||
| 28 | |||
| 17 | let score = 0; | 29 | let score = 0; |
| 18 | if (policy.actorId && actorId && policy.actorId === actorId) score += 4; | 30 | if (scopeType === "workspace" && policy.workspaceId === context.workspaceId) score += 16; |
| 19 | if (policy.clientId && clientId && policy.clientId === clientId) score += 2; | 31 | if (targetActorId && context.actorId && targetActorId === context.actorId) score += 64; |
| 20 | score += Math.max(1, policy.toolPathPattern.replace(/\*/g, "").length); | 32 | if (policy.clientId && context.clientId && policy.clientId === context.clientId) score += 4; |
| 33 | if (matchType === "exact") score += 3; | ||
| 34 | score += Math.max(1, resourcePattern.replace(/\*/g, "").length); | ||
| 21 | score += policy.priority; | 35 | score += policy.priority; |
| 22 | return score; | 36 | return score; |
| 23 | } | 37 | } |
| 24 | 38 | ||
| 39 | function resolvePolicyDecision(policy: AccessPolicyRecord, defaultDecision: PolicyDecision): PolicyDecision { | ||
| 40 | const effect = policy.effect ?? (policy.decision === "deny" ? "deny" : "allow"); | ||
| 41 | const approvalMode = policy.approvalMode | ||
| 42 | ?? (policy.decision === "require_approval" ? "required" : policy.decision === "allow" ? "auto" : "inherit"); | ||
| 43 | |||
| 44 | if (effect === "deny") { | ||
| 45 | return "deny"; | ||
| 46 | } | ||
| 47 | |||
| 48 | if (approvalMode === "required") { | ||
| 49 | return "require_approval"; | ||
| 50 | } | ||
| 51 | |||
| 52 | if (approvalMode === "auto") { | ||
| 53 | return "allow"; | ||
| 54 | } | ||
| 55 | |||
| 56 | return defaultDecision; | ||
| 57 | } | ||
| 58 | |||
| 25 | export function getDecisionForContext( | 59 | export function getDecisionForContext( |
| 26 | tool: ToolDefinition, | 60 | tool: ToolDefinition, |
| 27 | context: { workspaceId: string; actorId?: string; clientId?: string }, | 61 | context: { workspaceId: string; actorId?: string; clientId?: string }, |
| @@ -34,17 +68,24 @@ export function getDecisionForContext( · 6 unmodified lines | |||
| 34 | const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow"; | 68 | const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow"; |
| 35 | const candidates = policies | 69 | const candidates = policies |
| 36 | .filter((policy) => { | 70 | .filter((policy) => { |
| 37 | if (policy.actorId && policy.actorId !== context.actorId) return false; | 71 | const scopeType = policy.scopeType ?? (policy.workspaceId ? "workspace" : "organization"); |
| 72 | const targetActorId = policy.targetActorId ?? policy.actorId; | ||
| 73 | const resourcePattern = policy.resourcePattern ?? policy.toolPathPattern; | ||
| 74 | const matchType = policy.matchType ?? "glob"; | ||
| 75 | |||
| 76 | if (!resourcePattern) return false; | ||
| 77 | if (scopeType === "workspace" && policy.workspaceId !== context.workspaceId) return false; | ||
| 78 | if (targetActorId && targetActorId !== context.actorId) return false; | ||
| 38 | if (policy.clientId && policy.clientId !== context.clientId) return false; | 79 | if (policy.clientId && policy.clientId !== context.clientId) return false; |
| 39 | return matchesToolPath(policy.toolPathPattern, tool.path); | 80 | return matchesToolPath(resourcePattern, tool.path, matchType); |
| 40 | }) | 81 | }) |
| 41 | .sort( | 82 | .sort( |
| 42 | (a, b) => | 83 | (a, b) => |
| 43 | policySpecificity(b, context.actorId, context.clientId) | 84 | policySpecificity(b, context) |
| 44 | - policySpecificity(a, context.actorId, context.clientId), | 85 | - policySpecificity(a, context), |
| 45 | ); | 86 | ); |
| 46 | 87 | ||
| 47 | return candidates[0]?.decision ?? defaultDecision; | 88 | return candidates[0] ? resolvePolicyDecision(candidates[0], defaultDecision) : defaultDecision; |
| 48 | } | 89 | } |
| 49 | 90 | ||
| 50 | export function getToolDecision( | 91 | export function getToolDecision( |
| @@ -1,38 +1,21 @@ | |||
| 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"; | ||
| 5 | import type { ActionCtx } from "../../convex/_generated/server"; | 4 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 6 | import { internal } from "../../convex/_generated/api"; | 5 | import { internal } from "../../convex/_generated/api"; |
| 7 | import { resolveCredentialPayload } from "../../../core/src/credential-providers"; | 6 | import { resolveCredentialPayloadResult } from "../../../core/src/credential-providers"; |
| 7 | import { | ||
| 8 | buildCredentialAuthHeaders, | ||
| 9 | readCredentialOverrideHeaders, | ||
| 10 | } from "../../../core/src/tool/source-auth"; | ||
| 8 | import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types"; | 11 | import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types"; |
| 9 | import { ToolCallControlError } from "../../../core/src/tool-call-control"; | 12 | import { ToolCallControlError } from "../../../core/src/tool-call-control"; |
| 10 | import { asPayload } from "../lib/object"; | ||
| 11 | 13 | ||
| 12 | const bearerSecretSchema = z.object({ | 14 | export async function resolveCredentialHeadersResult( |
| 13 | token: z.coerce.string().optional(), | 15 | ctx: Pick<ActionCtx, "runQuery">, |
| 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 | |||
| 31 | export async function resolveCredentialHeaders( | ||
| 32 | ctx: ActionCtx, | ||
| 33 | spec: ToolCredentialSpec, | 16 | spec: ToolCredentialSpec, |
| 34 | task: TaskRecord, | 17 | task: TaskRecord, |
| 35 | ): Promise<ResolvedToolCredential | null> { | 18 | ): Promise<Result<ResolvedToolCredential | null, Error>> { |
| 36 | const record = await ctx.runQuery(internal.database.resolveCredential, { | 19 | const record = await ctx.runQuery(internal.database.resolveCredential, { |
| 37 | workspaceId: task.workspaceId, | 20 | workspaceId: task.workspaceId, |
| 38 | sourceKey: spec.sourceKey, | 21 | sourceKey: spec.sourceKey, |
| @@ -40,53 +23,55 @@ export async function resolveCredentialHeaders( · 1 unmodified line | |||
| 40 | actorId: task.actorId, | 23 | actorId: task.actorId, |
| 41 | }); | 24 | }); |
| 42 | 25 | ||
| 43 | const source = record | 26 | const sourceResult = record |
| 44 | ? await resolveCredentialPayload(record) | 27 | ? await resolveCredentialPayloadResult(record) |
| 45 | : spec.staticSecretJson ?? null; | 28 | : Result.ok(spec.staticSecretJson ?? null); |
| 46 | if (!source) { | 29 | if (sourceResult.isErr()) { |
| 47 | return null; | 30 | return Result.err( |
| 31 | new Error(`Failed to resolve credential payload for '${spec.sourceKey}': ${sourceResult.error.message}`), | ||
| 32 | ); | ||
| 48 | } | 33 | } |
| 49 | const sourcePayload = asPayload(source); | ||
| 50 | 34 | ||
| 51 | const headers: Record<string, string> = {}; | 35 | const source = sourceResult.value; |
| 52 | if (spec.authType === "bearer") { | 36 | if (!source) { |
| 53 | const parsedSecret = bearerSecretSchema.safeParse(sourcePayload); | 37 | return Result.ok(null); |
| 54 | const token = parsedSecret.success ? (parsedSecret.data.token ?? "").trim() : ""; | ||
| 55 | if (token) headers.authorization = `Bearer ${token}`; | ||
| 56 | } else if (spec.authType === "apiKey") { | ||
| 57 | const parsedSecret = apiKeySecretSchema.safeParse(sourcePayload); | ||
| 58 | const headerName = spec.headerName | ||
| 59 | ?? (parsedSecret.success ? (parsedSecret.data.headerName ?? "x-api-key") : "x-api-key"); | ||
| 60 | const value = parsedSecret.success | ||
| 61 | ? (parsedSecret.data.value ?? parsedSecret.data.token ?? "").trim() | ||
| 62 | : ""; | ||
| 63 | if (value) headers[headerName] = value; | ||
| 64 | } else if (spec.authType === "basic") { | ||
| 65 | const parsedSecret = basicSecretSchema.safeParse(sourcePayload); | ||
| 66 | const username = parsedSecret.success ? (parsedSecret.data.username ?? "") : ""; | ||
| 67 | const password = parsedSecret.success ? (parsedSecret.data.password ?? "") : ""; | ||
| 68 | if (username || password) { | ||
| 69 | const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); | ||
| 70 | headers.authorization = `Basic ${encoded}`; | ||
| 71 | } | ||
| 72 | } | 38 | } |
| 73 | 39 | ||
| 74 | const bindingOverrides = credentialOverrideHeadersSchema.safeParse(record?.overridesJson ?? {}); | 40 | const headers = buildCredentialAuthHeaders( |
| 75 | const overrideHeaders = bindingOverrides.success ? (bindingOverrides.data.headers ?? {}) : {}; | 41 | { |
| 42 | authType: spec.authType, | ||
| 43 | headerName: spec.headerName, | ||
| 44 | }, | ||
| 45 | source, | ||
| 46 | ); | ||
| 47 | |||
| 48 | const overrideHeaders = readCredentialOverrideHeaders(record?.overridesJson); | ||
| 76 | for (const [key, value] of Object.entries(overrideHeaders)) { | 49 | for (const [key, value] of Object.entries(overrideHeaders)) { |
| 77 | if (!key) continue; | ||
| 78 | headers[key] = value; | 50 | headers[key] = value; |
| 79 | } | 51 | } |
| 80 | 52 | ||
| 81 | if (Object.keys(headers).length === 0) { | 53 | if (Object.keys(headers).length === 0) { |
| 82 | return null; | 54 | return Result.ok(null); |
| 83 | } | 55 | } |
| 84 | 56 | ||
| 85 | return { | 57 | return Result.ok({ |
| 86 | sourceKey: spec.sourceKey, | 58 | sourceKey: spec.sourceKey, |
| 87 | mode: spec.mode, | 59 | mode: spec.mode, |
| 88 | headers, | 60 | headers, |
| 89 | }; | 61 | }); |
| 62 | } | ||
| 63 | |||
| 64 | export async function resolveCredentialHeaders( | ||
| 65 | ctx: Pick<ActionCtx, "runQuery">, | ||
| 66 | spec: ToolCredentialSpec, | ||
| 67 | task: TaskRecord, | ||
| 68 | ): Promise<ResolvedToolCredential | null> { | ||
| 69 | const result = await resolveCredentialHeadersResult(ctx, spec, task); | ||
| 70 | if (result.isErr()) { | ||
| 71 | throw result.error; | ||
| 72 | } | ||
| 73 | |||
| 74 | return result.value; | ||
| 90 | } | 75 | } |
| 91 | 76 | ||
| 92 | export function validatePersistedCallRunnable( | 77 | export function validatePersistedCallRunnable( |
| @@ -0,0 +1,54 @@ | |||
| 1 | import { describe, expect, test } from "bun:test"; | ||
| 2 | import type { TaskRecord, ToolDefinition } from "../../../core/src/types"; | ||
| 3 | import { getGraphqlDecision } from "./tool_call_resolution"; | ||
| 4 | |||
| 5 | const task: TaskRecord = { | ||
| 6 | id: "task_1", | ||
| 7 | code: "", | ||
| 8 | runtimeId: "runtime_1", | ||
| 9 | status: "running", | ||
| 10 | timeoutMs: 30_000, | ||
| 11 | metadata: {}, | ||
| 12 | workspaceId: "workspace_1" as TaskRecord["workspaceId"], | ||
| 13 | actorId: "actor_1", | ||
| 14 | clientId: "client_1", | ||
| 15 | createdAt: 1, | ||
| 16 | updatedAt: 1, | ||
| 17 | }; | ||
| 18 | |||
| 19 | const graphqlTool: ToolDefinition = { | ||
| 20 | path: "linear.graphql", | ||
| 21 | source: "graphql:linear", | ||
| 22 | approval: "auto", | ||
| 23 | description: "Raw GraphQL tool", | ||
| 24 | _graphqlSource: "linear", | ||
| 25 | run: async () => ({ data: null, errors: [] }), | ||
| 26 | }; | ||
| 27 | |||
| 28 | describe("getGraphqlDecision", () => { | ||
| 29 | test("supports raw string GraphQL query input", () => { | ||
| 30 | const decision = getGraphqlDecision( | ||
| 31 | task, | ||
| 32 | graphqlTool, | ||
| 33 | "query { teams { nodes { id } } }", | ||
| 34 | undefined, | ||
| 35 | [], | ||
| 36 | ); | ||
| 37 | |||
| 38 | expect(decision.decision).toBe("allow"); | ||
| 39 | expect(decision.effectivePaths).toEqual(["linear.query.teams"]); | ||
| 40 | }); | ||
| 41 | |||
| 42 | test("derives require_approval for raw string mutation input", () => { | ||
| 43 | const decision = getGraphqlDecision( | ||
| 44 | task, | ||
| 45 | graphqlTool, | ||
| 46 | "mutation { issueBatchCreate(input: { issues: [] }) { success } }", | ||
| 47 | undefined, | ||
| 48 | [], | ||
| 49 | ); | ||
| 50 | |||
| 51 | expect(decision.decision).toBe("require_approval"); | ||
| 52 | expect(decision.effectivePaths).toEqual(["linear.mutation.issuebatchcreate"]); | ||
| 53 | }); | ||
| 54 | }); | ||
| @@ -7,9 +7,8 @@ import { internal } from "../../convex/_generated/api"; · 6 unmodified lines | |||
| 7 | import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths"; | 7 | import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths"; |
| 8 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; | 8 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; |
| 9 | import { parseSerializedTool, rehydrateTools } from "../../../core/src/tool/source-serialization"; | 9 | import { parseSerializedTool, rehydrateTools } from "../../../core/src/tool/source-serialization"; |
| 10 | import { asPayload } from "../lib/object"; | ||
| 11 | import { getDecisionForContext, getToolDecision } from "./policy"; | 10 | import { getDecisionForContext, getToolDecision } from "./policy"; |
| 12 | import { getReadyRegistryBuildId } from "./tool_registry_state"; | 11 | import { getReadyRegistryBuildIdResult } from "./tool_registry_state"; |
| 13 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; | 12 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; |
| 14 | import { baseTools } from "./workspace_tools"; | 13 | import { baseTools } from "./workspace_tools"; |
| 15 | 14 | ||
| @@ -27,6 +26,24 @@ const registrySerializedToolEntrySchema = z.object({ · 11 unmodified lines | |||
| 27 | serializedToolJson: z.string(), | 26 | serializedToolJson: z.string(), |
| 28 | }); | 27 | }); |
| 29 | 28 | ||
| 29 | const graphqlDecisionInputSchema = z.union([ | ||
| 30 | z.string(), | ||
| 31 | z.object({ query: z.string().optional() }).catchall(z.unknown()), | ||
| 32 | ]); | ||
| 33 | |||
| 34 | function getGraphqlQueryFromInput(input: unknown): string { | ||
| 35 | const parsedInput = graphqlDecisionInputSchema.safeParse(input); | ||
| 36 | if (!parsedInput.success) { | ||
| 37 | return ""; | ||
| 38 | } | ||
| 39 | |||
| 40 | if (typeof parsedInput.data === "string") { | ||
| 41 | return parsedInput.data; | ||
| 42 | } | ||
| 43 | |||
| 44 | return parsedInput.data.query ?? ""; | ||
| 45 | } | ||
| 46 | |||
| 30 | async function searchRegistryEntries( | 47 | async function searchRegistryEntries( |
| 31 | ctx: ActionCtx, | 48 | ctx: ActionCtx, |
| 32 | args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number }, | 49 | args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number }, |
| @@ -66,9 +83,12 @@ export function getGraphqlDecision( · 33 unmodified lines | |||
| 66 | workspaceTools: Map<string, ToolDefinition> | undefined, | 83 | workspaceTools: Map<string, ToolDefinition> | undefined, |
| 67 | policies: AccessPolicyRecord[], | 84 | policies: AccessPolicyRecord[], |
| 68 | ): { decision: PolicyDecision; effectivePaths: string[] } { | 85 | ): { decision: PolicyDecision; effectivePaths: string[] } { |
| 69 | const sourceName = tool._graphqlSource!; | 86 | const sourceName = tool._graphqlSource; |
| 70 | const payload = asPayload(input); | 87 | if (!sourceName) { |
| 71 | const queryString = typeof payload.query === "string" ? payload.query : ""; | 88 | return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; |
| 89 | } | ||
| 90 | |||
| 91 | const queryString = getGraphqlQueryFromInput(input); | ||
| 72 | 92 | ||
| 73 | if (!queryString.trim()) { | 93 | if (!queryString.trim()) { |
| 74 | return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; | 94 | return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; |
| @@ -153,12 +173,16 @@ export async function resolveToolForCall( · 78 unmodified lines | |||
| 153 | return Result.ok({ tool: builtin, resolvedToolPath: toolPath }); | 173 | return Result.ok({ tool: builtin, resolvedToolPath: toolPath }); |
| 154 | } | 174 | } |
| 155 | 175 | ||
| 156 | const buildId = await getReadyRegistryBuildId(ctx, { | 176 | const buildIdResult = await getReadyRegistryBuildIdResult(ctx, { |
| 157 | workspaceId: task.workspaceId, | 177 | workspaceId: task.workspaceId, |
| 158 | actorId: task.actorId, | 178 | actorId: task.actorId, |
| 159 | clientId: task.clientId, | 179 | clientId: task.clientId, |
| 160 | refreshOnStale: true, | 180 | refreshOnStale: true, |
| 161 | }); | 181 | }); |
| 182 | if (buildIdResult.isErr()) { | ||
| 183 | return Result.err(buildIdResult.error); | ||
| 184 | } | ||
| 185 | const buildId = buildIdResult.value; | ||
| 162 | 186 | ||
| 163 | let resolvedToolPath = toolPath; | 187 | let resolvedToolPath = toolPath; |
| 164 | let entry = await getRegistryToolByPath(ctx, { | 188 | let entry = await getRegistryToolByPath(ctx, { |
| @@ -179,8 +203,12 @@ export async function resolveToolForCall( · 14 unmodified lines | |||
| 179 | if (hits.length > 0) { | 203 | if (hits.length > 0) { |
| 180 | // Prefer exact match on preferred path formatting, otherwise shortest canonical path. | 204 | // Prefer exact match on preferred path formatting, otherwise shortest canonical path. |
| 181 | const exact = hits.find((hit) => toPreferredToolPath(hit.path) === toPreferredToolPath(toolPath)); | 205 | const exact = hits.find((hit) => toPreferredToolPath(hit.path) === toPreferredToolPath(toolPath)); |
| 182 | entry = exact ?? hits.sort((a, b) => a.path.length - b.path.length || a.path.localeCompare(b.path))[0]!; | 206 | const shortest = [...hits] |
| 183 | resolvedToolPath = entry.path; | 207 | .sort((a, b) => a.path.length - b.path.length || a.path.localeCompare(b.path))[0]; |
| 208 | entry = exact ?? shortest ?? null; | ||
| 209 | if (entry) { | ||
| 210 | resolvedToolPath = entry.path; | ||
| 211 | } | ||
| 184 | } | 212 | } |
| 185 | } | 213 | } |
| 186 | 214 | ||
| @@ -189,15 +217,16 @@ export async function resolveToolForCall( · 2 unmodified lines | |||
| 189 | return Result.err(new Error(unknownToolErrorMessage(toolPath, suggestions))); | 217 | return Result.err(new Error(unknownToolErrorMessage(toolPath, suggestions))); |
| 190 | } | 218 | } |
| 191 | 219 | ||
| 192 | const serializedResult = Result.try(() => { | 220 | let parsedSerialized: unknown; |
| 193 | return JSON.parse(entry.serializedToolJson); | 221 | try { |
| 194 | }); | 222 | parsedSerialized = JSON.parse(entry.serializedToolJson); |
| 195 | if (serializedResult.isErr()) { | 223 | } catch (error) { |
| 224 | const message = error instanceof Error ? error.message : String(error); | ||
| 196 | return Result.err( | 225 | return Result.err( |
| 197 | new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serializedResult.error.message}`), | 226 | new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${message}`), |
| 198 | ); | 227 | ); |
| 199 | } | 228 | } |
| 200 | const serialized = parseSerializedTool(serializedResult.value); | 229 | const serialized = parseSerializedTool(parsedSerialized); |
| 201 | if (serialized.isErr()) { | 230 | if (serialized.isErr()) { |
| 202 | return Result.err( | 231 | return Result.err( |
| 203 | new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serialized.error.message}`), | 232 | new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serialized.error.message}`), |
| @@ -18,14 +18,15 @@ import { · 17 unmodified lines | |||
| 18 | decodeToolCallControlSignal, | 18 | decodeToolCallControlSignal, |
| 19 | ToolCallControlError, | 19 | ToolCallControlError, |
| 20 | } from "../../../core/src/tool-call-control"; | 20 | } from "../../../core/src/tool-call-control"; |
| 21 | import { asPayload } from "../lib/object"; | ||
| 22 | import { getToolDecision, getDecisionForContext } from "./policy"; | 21 | import { getToolDecision, getDecisionForContext } from "./policy"; |
| 23 | import { baseTools } from "./workspace_tools"; | 22 | import { baseTools } from "./workspace_tools"; |
| 24 | import { publishTaskEvent } from "./events"; | 23 | import { publishTaskEvent } from "./events"; |
| 25 | import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle"; | 24 | import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle"; |
| 26 | import { resolveCredentialHeaders, validatePersistedCallRunnable } from "./tool_call_credentials"; | 25 | import { resolveCredentialHeadersResult, validatePersistedCallRunnable } from "./tool_call_credentials"; |
| 27 | import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution"; | 26 | import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution"; |
| 28 | import { getReadyRegistryBuildId } from "./tool_registry_state"; | 27 | import { getReadyRegistryBuildIdResult } from "./tool_registry_state"; |
| 28 | |||
| 29 | const payloadRecordSchema = z.record(z.unknown()); | ||
| 29 | 30 | ||
| 30 | function createApprovalId(): string { | 31 | function createApprovalId(): string { |
| 31 | return `approval_${crypto.randomUUID()}`; | 32 | return `approval_${crypto.randomUUID()}`; |
| @@ -41,18 +42,6 @@ type RegistryToolEntry = { · 9 unmodified lines | |||
| 41 | displayOutput?: string; | 42 | displayOutput?: string; |
| 42 | }; | 43 | }; |
| 43 | 44 | ||
| 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({ | 45 | const registryNamespaceSchema = z.object({ |
| 57 | namespace: z.string(), | 46 | namespace: z.string(), |
| 58 | toolCount: z.number(), | 47 | toolCount: z.number(), |
| @@ -85,6 +74,15 @@ const discoverInputSchema = z.object({ · 26 unmodified lines | |||
| 85 | compact: z.boolean().optional(), | 74 | compact: z.boolean().optional(), |
| 86 | }); | 75 | }); |
| 87 | 76 | ||
| 77 | function toInputPayload(value: unknown): Record<string, unknown> { | ||
| 78 | const parsed = payloadRecordSchema.safeParse(value); | ||
| 79 | if (parsed.success) { | ||
| 80 | return parsed.data; | ||
| 81 | } | ||
| 82 | |||
| 83 | return value === undefined ? {} : { value }; | ||
| 84 | } | ||
| 85 | |||
| 88 | async function upsertRequestedToolCall( | 86 | async function upsertRequestedToolCall( |
| 89 | ctx: ActionCtx, | 87 | ctx: ActionCtx, |
| 90 | args: { taskId: string; callId: string; workspaceId: TaskRecord["workspaceId"]; toolPath: string }, | 88 | args: { taskId: string; callId: string; workspaceId: TaskRecord["workspaceId"]; toolPath: string }, |
| @@ -98,8 +96,7 @@ async function listWorkspaceAccessPolicies( · 7 unmodified lines | |||
| 98 | workspaceId: TaskRecord["workspaceId"], | 96 | workspaceId: TaskRecord["workspaceId"], |
| 99 | ): Promise<AccessPolicyRecord[]> { | 97 | ): Promise<AccessPolicyRecord[]> { |
| 100 | const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId }); | 98 | const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId }); |
| 101 | const parsed = z.array(accessPolicyRecordSchema).safeParse(policies); | 99 | return policies as AccessPolicyRecord[]; |
| 102 | return parsed.success ? parsed.data : []; | ||
| 103 | } | 100 | } |
| 104 | 101 | ||
| 105 | async function listRegistryNamespaces( | 102 | async function listRegistryNamespaces( |
| @@ -183,16 +180,20 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 77 unmodified lines | |||
| 183 | 180 | ||
| 184 | // Fast system tools are handled server-side from the registry. | 181 | // Fast system tools are handled server-side from the registry. |
| 185 | if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") { | 182 | if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") { |
| 186 | const buildId = await getReadyRegistryBuildId(ctx, { | 183 | const buildIdResult = await getReadyRegistryBuildIdResult(ctx, { |
| 187 | workspaceId: task.workspaceId, | 184 | workspaceId: task.workspaceId, |
| 188 | actorId: task.actorId, | 185 | actorId: task.actorId, |
| 189 | clientId: task.clientId, | 186 | clientId: task.clientId, |
| 190 | refreshOnStale: true, | 187 | refreshOnStale: true, |
| 191 | }); | 188 | }); |
| 189 | if (buildIdResult.isErr()) { | ||
| 190 | throw buildIdResult.error; | ||
| 191 | } | ||
| 192 | const buildId = buildIdResult.value; | ||
| 192 | 193 | ||
| 193 | const payload = typeof input === "string" | 194 | const payload = typeof input === "string" |
| 194 | ? { query: input } | 195 | ? { query: input } |
| 195 | : asPayload(input); | 196 | : toInputPayload(input); |
| 196 | const isAllowed = (path: string, approval: ToolDefinition["approval"]) => { | 197 | const isAllowed = (path: string, approval: ToolDefinition["approval"]) => { |
| 197 | const policyProbeTool: ToolDefinition = { | 198 | const policyProbeTool: ToolDefinition = { |
| 198 | path, | 199 | path, |
| @@ -339,7 +340,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 140 unmodified lines | |||
| 339 | 340 | ||
| 340 | let credential: ResolvedToolCredential | undefined; | 341 | let credential: ResolvedToolCredential | undefined; |
| 341 | if (tool.credential) { | 342 | if (tool.credential) { |
| 342 | const resolved = await resolveCredentialHeaders(ctx, tool.credential, task); | 343 | const credentialResult = await resolveCredentialHeadersResult(ctx, tool.credential, task); |
| 344 | if (credentialResult.isErr()) { | ||
| 345 | throw credentialResult.error; | ||
| 346 | } | ||
| 347 | |||
| 348 | const resolved = credentialResult.value; | ||
| 343 | if (!resolved) { | 349 | if (!resolved) { |
| 344 | throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`); | 350 | throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`); |
| 345 | } | 351 | } |
| @@ -402,7 +408,7 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 56 unmodified lines | |||
| 402 | taskId: task.id, | 408 | taskId: task.id, |
| 403 | callId, | 409 | callId, |
| 404 | toolPath: approval.toolPath, | 410 | toolPath: approval.toolPath, |
| 405 | input: asPayload(approval.input), | 411 | input: toInputPayload(approval.input), |
| 406 | createdAt: approval.createdAt, | 412 | createdAt: approval.createdAt, |
| 407 | }); | 413 | }); |
| 408 | } | 414 | } |
| @@ -1,5 +1,6 @@ | |||
| 1 | "use node"; | 1 | "use node"; |
| 2 | 2 | ||
| 3 | import { Result } from "better-result"; | ||
| 3 | import { z } from "zod"; | 4 | import { z } from "zod"; |
| 4 | import type { ActionCtx } from "../../convex/_generated/server"; | 5 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 5 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; | 6 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| @@ -21,12 +22,6 @@ type RegistryState = { · 15 unmodified lines | |||
| 21 | readyBuildId?: string; | 22 | readyBuildId?: string; |
| 22 | } | null; | 23 | } | null; |
| 23 | 24 | ||
| 24 | type ToolSourceState = { | ||
| 25 | id: string; | ||
| 26 | updatedAt: number; | ||
| 27 | enabled: boolean; | ||
| 28 | }; | ||
| 29 | |||
| 30 | const registryStateSchema = z.object({ | 25 | const registryStateSchema = z.object({ |
| 31 | signature: z.string(), | 26 | signature: z.string(), |
| 32 | readyBuildId: z.string().optional(), | 27 | readyBuildId: z.string().optional(), |
| @@ -34,9 +29,29 @@ const registryStateSchema = z.object({ · 1 unmodified line | |||
| 34 | 29 | ||
| 35 | const toolSourceStateSchema = z.object({ | 30 | const toolSourceStateSchema = z.object({ |
| 36 | id: z.string(), | 31 | id: z.string(), |
| 32 | type: z.string().optional(), | ||
| 33 | ownerScopeType: z.string().optional(), | ||
| 34 | organizationId: z.string().optional(), | ||
| 35 | workspaceId: z.string().optional(), | ||
| 36 | specHash: z.string().optional(), | ||
| 37 | authFingerprint: z.string().optional(), | ||
| 37 | updatedAt: z.number(), | 38 | updatedAt: z.number(), |
| 38 | enabled: z.boolean().optional(), | 39 | enabled: z.boolean().optional(), |
| 39 | }); | 40 | }).transform((source) => ({ |
| 41 | id: source.id, | ||
| 42 | type: source.type, | ||
| 43 | ownerScopeType: source.ownerScopeType, | ||
| 44 | organizationId: source.organizationId, | ||
| 45 | workspaceId: source.workspaceId, | ||
| 46 | specHash: source.specHash, | ||
| 47 | authFingerprint: source.authFingerprint, | ||
| 48 | updatedAt: source.updatedAt, | ||
| 49 | enabled: source.enabled !== false, | ||
| 50 | })); | ||
| 51 | |||
| 52 | type ToolSourceState = z.infer<typeof toolSourceStateSchema>; | ||
| 53 | |||
| 54 | const toolSourceStateListSchema = z.array(toolSourceStateSchema); | ||
| 40 | 55 | ||
| 41 | function toRegistryState(value: unknown): RegistryState { | 56 | function toRegistryState(value: unknown): RegistryState { |
| 42 | const parsed = registryStateSchema.safeParse(value); | 57 | const parsed = registryStateSchema.safeParse(value); |
| @@ -51,23 +66,12 @@ function toRegistryState(value: unknown): RegistryState { · 8 unmodified lines | |||
| 51 | } | 66 | } |
| 52 | 67 | ||
| 53 | function toToolSourceStateList(value: unknown): ToolSourceState[] { | 68 | function toToolSourceStateList(value: unknown): ToolSourceState[] { |
| 54 | if (!Array.isArray(value)) { | 69 | const parsed = toolSourceStateListSchema.safeParse(value); |
| 70 | if (!parsed.success) { | ||
| 55 | return []; | 71 | return []; |
| 56 | } | 72 | } |
| 57 | 73 | ||
| 58 | const normalized: ToolSourceState[] = []; | 74 | return parsed.data; |
| 59 | for (const entry of value) { | ||
| 60 | const parsed = toolSourceStateSchema.safeParse(entry); | ||
| 61 | if (!parsed.success) continue; | ||
| 62 | |||
| 63 | normalized.push({ | ||
| 64 | id: parsed.data.id, | ||
| 65 | updatedAt: parsed.data.updatedAt, | ||
| 66 | enabled: parsed.data.enabled !== false, | ||
| 67 | }); | ||
| 68 | } | ||
| 69 | |||
| 70 | return normalized; | ||
| 71 | } | 75 | } |
| 72 | 76 | ||
| 73 | async function readRegistryState( | 77 | async function readRegistryState( |
| @@ -90,7 +94,7 @@ async function readRegistryState( · 16 unmodified lines | |||
| 90 | }; | 94 | }; |
| 91 | } | 95 | } |
| 92 | 96 | ||
| 93 | export async function getReadyRegistryBuildId( | 97 | export async function getReadyRegistryBuildIdResult( |
| 94 | ctx: Pick<ActionCtx, "runQuery" | "runAction">, | 98 | ctx: Pick<ActionCtx, "runQuery" | "runAction">, |
| 95 | args: { | 99 | args: { |
| 96 | workspaceId: Id<"workspaces">; | 100 | workspaceId: Id<"workspaces">; |
| @@ -98,10 +102,10 @@ export async function getReadyRegistryBuildId( · 1 unmodified line | |||
| 98 | clientId?: string; | 102 | clientId?: string; |
| 99 | refreshOnStale?: boolean; | 103 | refreshOnStale?: boolean; |
| 100 | }, | 104 | }, |
| 101 | ): Promise<string> { | 105 | ): Promise<Result<string, Error>> { |
| 102 | const initial = await readRegistryState(ctx, args.workspaceId); | 106 | const initial = await readRegistryState(ctx, args.workspaceId); |
| 103 | if (initial.isReady && initial.buildId) { | 107 | if (initial.isReady && initial.buildId) { |
| 104 | return initial.buildId; | 108 | return Result.ok(initial.buildId); |
| 105 | } | 109 | } |
| 106 | 110 | ||
| 107 | if (args.refreshOnStale) { | 111 | if (args.refreshOnStale) { |
| @@ -113,11 +117,28 @@ export async function getReadyRegistryBuildId( · 5 unmodified lines | |||
| 113 | 117 | ||
| 114 | const refreshed = await readRegistryState(ctx, args.workspaceId); | 118 | const refreshed = await readRegistryState(ctx, args.workspaceId); |
| 115 | if (refreshed.isReady && refreshed.buildId) { | 119 | if (refreshed.isReady && refreshed.buildId) { |
| 116 | return refreshed.buildId; | 120 | return Result.ok(refreshed.buildId); |
| 117 | } | 121 | } |
| 118 | } | 122 | } |
| 119 | 123 | ||
| 120 | throw new Error( | 124 | return Result.err( |
| 121 | "Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild.", | 125 | new Error("Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild."), |
| 122 | ); | 126 | ); |
| 123 | } | 127 | } |
| 128 | |||
| 129 | export async function getReadyRegistryBuildId( | ||
| 130 | ctx: Pick<ActionCtx, "runQuery" | "runAction">, | ||
| 131 | args: { | ||
| 132 | workspaceId: Id<"workspaces">; | ||
| 133 | actorId?: string; | ||
| 134 | clientId?: string; | ||
| 135 | refreshOnStale?: boolean; | ||
| 136 | }, | ||
| 137 | ): Promise<string> { | ||
| 138 | const buildIdResult = await getReadyRegistryBuildIdResult(ctx, args); | ||
| 139 | if (buildIdResult.isErr()) { | ||
| 140 | throw buildIdResult.error; | ||
| 141 | } | ||
| 142 | |||
| 143 | return buildIdResult.value; | ||
| 144 | } | ||
| @@ -11,8 +11,12 @@ import { · 10 unmodified lines | |||
| 11 | prepareOpenApiSpec, | 11 | prepareOpenApiSpec, |
| 12 | type CompiledToolSourceArtifact, | 12 | type CompiledToolSourceArtifact, |
| 13 | } from "../../../core/src/tool-sources"; | 13 | } from "../../../core/src/tool-sources"; |
| 14 | import { resolveCredentialPayload } from "../../../core/src/credential-providers"; | 14 | import { resolveCredentialPayloadResult } from "../../../core/src/credential-providers"; |
| 15 | import { buildStaticAuthHeaders } from "../../../core/src/tool/source-auth"; | 15 | import { |
| 16 | buildCredentialAuthHeaders, | ||
| 17 | buildStaticAuthHeaders, | ||
| 18 | readCredentialOverrideHeaders, | ||
| 19 | } from "../../../core/src/tool/source-auth"; | ||
| 16 | import type { | 20 | import type { |
| 17 | ExternalToolSourceConfig, | 21 | ExternalToolSourceConfig, |
| 18 | GraphqlToolSourceConfig, | 22 | GraphqlToolSourceConfig, |
| @@ -28,6 +32,7 @@ const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; · 9 unmodified lines | |||
| 28 | 32 | ||
| 29 | /** Cache version - bump when tool snapshot/registry/type-hint semantics change. */ | 33 | /** Cache version - bump when tool snapshot/registry/type-hint semantics change. */ |
| 30 | const TOOL_SOURCE_CACHE_VERSION = "v25"; | 34 | const TOOL_SOURCE_CACHE_VERSION = "v25"; |
| 35 | const OPENAPI_PREPARED_CACHE_VERSION = "openapi_v1"; | ||
| 31 | 36 | ||
| 32 | const openApiAuthModeSchema = z.enum(["static", "workspace", "actor"]); | 37 | const openApiAuthModeSchema = z.enum(["static", "workspace", "actor"]); |
| 33 | 38 | ||
| @@ -52,19 +57,6 @@ const openApiAuthSchema = z.union([ · 18 unmodified lines | |||
| 52 | }), | 57 | }), |
| 53 | ]); | 58 | ]); |
| 54 | 59 | ||
| 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({ | 60 | const preparedOpenApiSpecSchema = z.object({ |
| 69 | servers: z.array(z.string()), | 61 | servers: z.array(z.string()), |
| 70 | paths: z.record(z.unknown()), | 62 | paths: z.record(z.unknown()), |
| @@ -99,9 +91,31 @@ function toPreparedOpenApiSpec(value: unknown): PreparedOpenApiSpec | null { · 28 unmodified lines | |||
| 99 | }; | 91 | }; |
| 100 | } | 92 | } |
| 101 | 93 | ||
| 102 | export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { | 94 | export function sourceSignature( |
| 95 | workspaceId: string, | ||
| 96 | sources: Array<{ | ||
| 97 | id: string; | ||
| 98 | type?: string; | ||
| 99 | ownerScopeType?: string; | ||
| 100 | organizationId?: string; | ||
| 101 | workspaceId?: string; | ||
| 102 | specHash?: string; | ||
| 103 | authFingerprint?: string; | ||
| 104 | updatedAt: number; | ||
| 105 | enabled: boolean; | ||
| 106 | }>, | ||
| 107 | ): string { | ||
| 103 | const parts = sources | 108 | const parts = sources |
| 104 | .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`) | 109 | .map((source) => { |
| 110 | const type = source.type ?? "unknown"; | ||
| 111 | const ownerScopeType = source.ownerScopeType ?? "workspace"; | ||
| 112 | const org = source.organizationId ?? ""; | ||
| 113 | const ws = source.workspaceId ?? ""; | ||
| 114 | const specHash = source.specHash ?? ""; | ||
| 115 | const authFingerprint = source.authFingerprint ?? ""; | ||
| 116 | const enabled = source.enabled ? 1 : 0; | ||
| 117 | return `${source.id}:${type}:${ownerScopeType}:${org}:${ws}:${specHash}:${authFingerprint}:${source.updatedAt}:${enabled}`; | ||
| 118 | }) | ||
| 105 | .sort(); | 119 | .sort(); |
| 106 | return `${TOOL_SOURCE_CACHE_VERSION}|${workspaceId}|${parts.join(",")}`; | 120 | return `${TOOL_SOURCE_CACHE_VERSION}|${workspaceId}|${parts.join(",")}`; |
| 107 | } | 121 | } |
| @@ -189,36 +203,23 @@ export function normalizeExternalToolSource(raw: { · 81 unmodified lines | |||
| 189 | return Result.ok(result); | 203 | return Result.ok(result); |
| 190 | } | 204 | } |
| 191 | 205 | ||
| 192 | function buildHeadersFromCredentialSecret( | 206 | function toCredentialHeaderSpec(auth: OpenApiAuth): |
| 193 | auth: OpenApiAuth, | 207 | | { authType: "bearer" | "apiKey" | "basic"; headerName?: string } |
| 194 | secret: Record<string, unknown>, | 208 | | null { |
| 195 | ): Record<string, string> { | 209 | if (auth.type === "none") { |
| 196 | if (auth.type === "bearer") { | 210 | return null; |
| 197 | const parsedSecret = bearerSecretSchema.safeParse(secret); | ||
| 198 | const token = parsedSecret.success ? (parsedSecret.data.token ?? "").trim() : ""; | ||
| 199 | return token ? { authorization: `Bearer ${token}` } : {}; | ||
| 200 | } | 211 | } |
| 201 | 212 | ||
| 202 | if (auth.type === "apiKey") { | 213 | if (auth.type === "apiKey") { |
| 203 | const parsedSecret = apiKeySecretSchema.safeParse(secret); | 214 | return { |
| 204 | const value = parsedSecret.success | 215 | authType: "apiKey", |
| 205 | ? (parsedSecret.data.value ?? parsedSecret.data.token ?? "").trim() | 216 | headerName: auth.header, |
| 206 | : ""; | 217 | }; |
| 207 | return value ? { [auth.header]: value } : {}; | ||
| 208 | } | ||
| 209 | |||
| 210 | if (auth.type === "basic") { | ||
| 211 | const parsedSecret = basicSecretSchema.safeParse(secret); | ||
| 212 | const username = parsedSecret.success ? (parsedSecret.data.username ?? "") : ""; | ||
| 213 | const password = parsedSecret.success ? (parsedSecret.data.password ?? "") : ""; | ||
| 214 | if (!username && !password) { | ||
| 215 | return {}; | ||
| 216 | } | ||
| 217 | const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); | ||
| 218 | return { authorization: `Basic ${encoded}` }; | ||
| 219 | } | 218 | } |
| 220 | 219 | ||
| 221 | return {}; | 220 | return { |
| 221 | authType: auth.type, | ||
| 222 | }; | ||
| 222 | } | 223 | } |
| 223 | 224 | ||
| 224 | async function resolveMcpDiscoveryHeaders( | 225 | async function resolveMcpDiscoveryHeaders( |
| @@ -258,38 +259,41 @@ async function resolveMcpDiscoveryHeaders( · 33 unmodified lines | |||
| 258 | }; | 259 | }; |
| 259 | } | 260 | } |
| 260 | 261 | ||
| 261 | try { | 262 | const secretResult = await resolveCredentialPayloadResult(record); |
| 262 | const secret = await resolveCredentialPayload(record); | 263 | if (secretResult.isErr()) { |
| 263 | if (!secret) { | 264 | return { |
| 264 | return { | 265 | headers: {}, |
| 265 | headers: {}, | 266 | warnings: [`Source '${source.name}': failed to resolve MCP credential for discovery: ${secretResult.error.message}`], |
| 266 | warnings: [`Source '${source.name}': credential payload unavailable for MCP discovery`], | 267 | }; |
| 267 | }; | 268 | } |
| 268 | } | ||
| 269 | 269 | ||
| 270 | const headers = buildHeadersFromCredentialSecret(auth, secret); | 270 | const secret = secretResult.value; |
| 271 | const parsedOverrides = credentialOverrideHeadersSchema.safeParse(record.overridesJson ?? {}); | 271 | if (!secret) { |
| 272 | const overrideHeaders = parsedOverrides.success ? (parsedOverrides.data.headers ?? {}) : {}; | 272 | return { |
| 273 | for (const [key, value] of Object.entries(overrideHeaders)) { | 273 | headers: {}, |
| 274 | if (!key) continue; | 274 | warnings: [`Source '${source.name}': credential payload unavailable for MCP discovery`], |
| 275 | headers[key] = value; | 275 | }; |
| 276 | } | 276 | } |
| 277 | 277 | ||
| 278 | if (Object.keys(headers).length === 0) { | 278 | const authSpec = toCredentialHeaderSpec(auth); |
| 279 | return { | 279 | if (!authSpec) { |
| 280 | headers: {}, | 280 | return { headers: {}, warnings: [] }; |
| 281 | warnings: [`Source '${source.name}': credential did not produce MCP auth headers for discovery`], | 281 | } |
| 282 | }; | ||
| 283 | } | ||
| 284 | 282 | ||
| 285 | return { headers, warnings: [] }; | 283 | const headers = buildCredentialAuthHeaders(authSpec, secret); |
| 286 | } catch (error) { | 284 | const overrideHeaders = readCredentialOverrideHeaders(record.overridesJson); |
| 287 | const message = error instanceof Error ? error.message : String(error); | 285 | for (const [key, value] of Object.entries(overrideHeaders)) { |
| 286 | headers[key] = value; | ||
| 287 | } | ||
| 288 | |||
| 289 | if (Object.keys(headers).length === 0) { | ||
| 288 | return { | 290 | return { |
| 289 | headers: {}, | 291 | headers: {}, |
| 290 | warnings: [`Source '${source.name}': failed to resolve MCP credential for discovery: ${message}`], | 292 | warnings: [`Source '${source.name}': credential did not produce MCP auth headers for discovery`], |
| 291 | }; | 293 | }; |
| 292 | } | 294 | } |
| 295 | |||
| 296 | return { headers, warnings: [] }; | ||
| 293 | } | 297 | } |
| 294 | 298 | ||
| 295 | async function loadCachedOpenApiSpec( | 299 | async function loadCachedOpenApiSpec( |
| @@ -308,7 +312,7 @@ async function loadCachedOpenApiSpec( · 12 unmodified lines | |||
| 308 | try { | 312 | try { |
| 309 | const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, { | 313 | const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, { |
| 310 | specUrl, | 314 | specUrl, |
| 311 | version: TOOL_SOURCE_CACHE_VERSION, | 315 | version: OPENAPI_PREPARED_CACHE_VERSION, |
| 312 | maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS, | 316 | maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS, |
| 313 | }); | 317 | }); |
| 314 | 318 | ||
| @@ -316,9 +320,15 @@ async function loadCachedOpenApiSpec( · 1 unmodified line | |||
| 316 | const blob = await ctx.storage.get(entry.storageId); | 320 | const blob = await ctx.storage.get(entry.storageId); |
| 317 | if (blob) { | 321 | if (blob) { |
| 318 | const json = await blob.text(); | 322 | const json = await blob.text(); |
| 319 | const parsedResult = Result.try(() => JSON.parse(json)); | 323 | let parsedJson: unknown; |
| 320 | if (parsedResult.isOk()) { | 324 | try { |
| 321 | const prepared = toPreparedOpenApiSpec(parsedResult.value); | 325 | parsedJson = JSON.parse(json); |
| 326 | } catch { | ||
| 327 | parsedJson = undefined; | ||
| 328 | } | ||
| 329 | |||
| 330 | if (parsedJson !== undefined) { | ||
| 331 | const prepared = toPreparedOpenApiSpec(parsedJson); | ||
| 322 | if (prepared && (!includeDts || getDtsStatus(prepared) !== "skipped")) { | 332 | if (prepared && (!includeDts || getDtsStatus(prepared) !== "skipped")) { |
| 323 | return prepared; | 333 | return prepared; |
| 324 | } | 334 | } |
| @@ -338,7 +348,7 @@ async function loadCachedOpenApiSpec( · 13 unmodified lines | |||
| 338 | const storageId = await ctx.storage.store(blob); | 348 | const storageId = await ctx.storage.store(blob); |
| 339 | await ctx.runMutation(internal.openApiSpecCache.putEntry, { | 349 | await ctx.runMutation(internal.openApiSpecCache.putEntry, { |
| 340 | specUrl, | 350 | specUrl, |
| 341 | version: TOOL_SOURCE_CACHE_VERSION, | 351 | version: OPENAPI_PREPARED_CACHE_VERSION, |
| 342 | storageId, | 352 | storageId, |
| 343 | sizeBytes: json.length, | 353 | sizeBytes: json.length, |
| 344 | }); | 354 | }); |
| @@ -354,7 +364,7 @@ export async function loadSourceArtifact( · 9 unmodified lines | |||
| 354 | ctx: ActionCtx, | 364 | ctx: ActionCtx, |
| 355 | source: ExternalToolSourceConfig, | 365 | source: ExternalToolSourceConfig, |
| 356 | options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string }, | 366 | options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string }, |
| 357 | ): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[]; openApiDts?: string }> { | 367 | ): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[]; openApiDts?: string; openApiSourceKey?: string }> { |
| 358 | const includeDts = options.includeDts ?? true; | 368 | const includeDts = options.includeDts ?? true; |
| 359 | 369 | ||
| 360 | if (source.type === "openapi" && typeof source.spec === "string") { | 370 | if (source.type === "openapi" && typeof source.spec === "string") { |
| @@ -364,7 +374,12 @@ export async function loadSourceArtifact( · 3 unmodified lines | |||
| 364 | const warnings = (prepared.warnings ?? []).map( | 374 | const warnings = (prepared.warnings ?? []).map( |
| 365 | (warning) => `Source '${source.name}': ${warning}`, | 375 | (warning) => `Source '${source.name}': ${warning}`, |
| 366 | ); | 376 | ); |
| 367 | return { artifact, warnings, openApiDts: prepared.dts }; | 377 | return { |
| 378 | artifact, | ||
| 379 | warnings, | ||
| 380 | openApiDts: prepared.dts, | ||
| 381 | openApiSourceKey: source.sourceKey ?? `openapi:${source.name}`, | ||
| 382 | }; | ||
| 368 | } catch (error) { | 383 | } catch (error) { |
| 369 | const message = error instanceof Error ? error.message : String(error); | 384 | const message = error instanceof Error ? error.message : String(error); |
| 370 | return { | 385 | return { |
| @@ -30,7 +30,6 @@ import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_ · 29 unmodified lines | |||
| 30 | import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading"; | 30 | import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading"; |
| 31 | import { registrySignatureForWorkspace } from "./tool_registry_state"; | 31 | import { registrySignatureForWorkspace } from "./tool_registry_state"; |
| 32 | import { normalizeToolPathForLookup } from "./tool_paths"; | 32 | import { normalizeToolPathForLookup } from "./tool_paths"; |
| 33 | import { asPayload } from "../lib/object"; | ||
| 34 | 33 | ||
| 35 | const baseTools = new Map<string, ToolDefinition>(); | 34 | const baseTools = new Map<string, ToolDefinition>(); |
| 36 | 35 | ||
| @@ -39,6 +38,33 @@ const adminAnnouncementInputSchema = z.object({ · 2 unmodified lines | |||
| 39 | message: z.string().optional(), | 38 | message: z.string().optional(), |
| 40 | }); | 39 | }); |
| 41 | 40 | ||
| 41 | const actorScopedMcpAuthSchema = z.object({ | ||
| 42 | mode: z.literal("actor"), | ||
| 43 | }); | ||
| 44 | |||
| 45 | const toolHintSchema = z.object({ | ||
| 46 | inputHint: z.string().optional(), | ||
| 47 | outputHint: z.string().optional(), | ||
| 48 | requiredInputKeys: z.array(z.string()).optional(), | ||
| 49 | previewInputKeys: z.array(z.string()).optional(), | ||
| 50 | }); | ||
| 51 | |||
| 52 | const payloadRecordSchema = z.record(z.unknown()); | ||
| 53 | |||
| 54 | function toInputPayload(value: unknown): Record<string, unknown> { | ||
| 55 | const parsed = payloadRecordSchema.safeParse(value); | ||
| 56 | if (parsed.success) { | ||
| 57 | return parsed.data; | ||
| 58 | } | ||
| 59 | |||
| 60 | return value === undefined ? {} : { value }; | ||
| 61 | } | ||
| 62 | |||
| 63 | function toJsonSchema(value: unknown): JsonSchema { | ||
| 64 | const parsed = payloadRecordSchema.safeParse(value); | ||
| 65 | return parsed.success ? parsed.data : {}; | ||
| 66 | } | ||
| 67 | |||
| 42 | async function listWorkspaceToolSources( | 68 | async function listWorkspaceToolSources( |
| 43 | ctx: ActionCtx, | 69 | ctx: ActionCtx, |
| 44 | workspaceId: Id<"workspaces">, | 70 | workspaceId: Id<"workspaces">, |
| @@ -56,17 +82,23 @@ async function listWorkspaceAccessPolicies( · 11 unmodified lines | |||
| 56 | } | 82 | } |
| 57 | 83 | ||
| 58 | async function parseWorkspaceToolSnapshotFromBlob(blob: Blob): Promise<Result<WorkspaceToolSnapshot, Error>> { | 84 | async function parseWorkspaceToolSnapshotFromBlob(blob: Blob): Promise<Result<WorkspaceToolSnapshot, Error>> { |
| 59 | const textResult = await Result.tryPromise(async () => await blob.text()); | 85 | let text: string; |
| 60 | if (textResult.isErr()) { | 86 | try { |
| 61 | return Result.err(new Error(`Failed to read cache blob: ${textResult.error.message}`)); | 87 | text = await blob.text(); |
| 88 | } catch (error) { | ||
| 89 | const message = error instanceof Error ? error.message : String(error); | ||
| 90 | return Result.err(new Error(`Failed to read cache blob: ${message}`)); | ||
| 62 | } | 91 | } |
| 63 | 92 | ||
| 64 | const jsonResult = Result.try(() => JSON.parse(textResult.value)); | 93 | let parsedJson: unknown; |
| 65 | if (jsonResult.isErr()) { | 94 | try { |
| 66 | return Result.err(new Error(`Failed to parse cache JSON: ${jsonResult.error.message}`)); | 95 | parsedJson = JSON.parse(text); |
| 96 | } catch (error) { | ||
| 97 | const message = error instanceof Error ? error.message : String(error); | ||
| 98 | return Result.err(new Error(`Failed to parse cache JSON: ${message}`)); | ||
| 67 | } | 99 | } |
| 68 | 100 | ||
| 69 | return parseWorkspaceToolSnapshot(jsonResult.value); | 101 | return parseWorkspaceToolSnapshot(parsedJson); |
| 70 | } | 102 | } |
| 71 | 103 | ||
| 72 | // Minimal built-in tools used by tests/demos. | 104 | // Minimal built-in tools used by tests/demos. |
| @@ -98,7 +130,7 @@ baseTools.set("admin.send_announcement", { · 25 unmodified lines | |||
| 98 | }, | 130 | }, |
| 99 | }, | 131 | }, |
| 100 | run: async (input: unknown) => { | 132 | run: async (input: unknown) => { |
| 101 | const parsedInput = adminAnnouncementInputSchema.safeParse(asPayload(input)); | 133 | const parsedInput = adminAnnouncementInputSchema.safeParse(toInputPayload(input)); |
| 102 | const channel = parsedInput.success ? (parsedInput.data.channel ?? "") : ""; | 134 | const channel = parsedInput.success ? (parsedInput.data.channel ?? "") : ""; |
| 103 | const message = parsedInput.success ? (parsedInput.data.message ?? "") : ""; | 135 | const message = parsedInput.success ? (parsedInput.data.message ?? "") : ""; |
| 104 | return { ok: true, channel, message }; | 136 | return { ok: true, channel, message }; |
| @@ -401,13 +433,6 @@ function normalizeHint(type?: string): string { · 296 unmodified lines | |||
| 401 | return type && type.trim().length > 0 ? type : "unknown"; | 433 | return type && type.trim().length > 0 ? type : "unknown"; |
| 402 | } | 434 | } |
| 403 | 435 | ||
| 404 | function asJsonSchema(value: unknown): JsonSchema { | ||
| 405 | if (!value || typeof value !== "object" || Array.isArray(value)) { | ||
| 406 | return {}; | ||
| 407 | } | ||
| 408 | return asPayload(value); | ||
| 409 | } | ||
| 410 | |||
| 411 | async function buildWorkspaceToolRegistry( | 436 | async function buildWorkspaceToolRegistry( |
| 412 | ctx: ActionCtx, | 437 | ctx: ActionCtx, |
| 413 | args: { | 438 | args: { |
| @@ -433,24 +458,24 @@ async function buildWorkspaceToolRegistry( · 19 unmodified lines | |||
| 433 | const normalizedPath = normalizeToolPathForLookup(st.path); | 458 | const normalizedPath = normalizeToolPathForLookup(st.path); |
| 434 | const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase(); | 459 | const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase(); |
| 435 | 460 | ||
| 436 | const inputSchema = asJsonSchema(st.typing?.inputSchema); | 461 | const inputSchema = toJsonSchema(st.typing?.inputSchema); |
| 437 | const outputSchema = asJsonSchema(st.typing?.outputSchema); | 462 | const outputSchema = toJsonSchema(st.typing?.outputSchema); |
| 463 | const parsedTyping = toolHintSchema.safeParse(st.typing); | ||
| 464 | const typing = parsedTyping.success ? parsedTyping.data : {}; | ||
| 438 | 465 | ||
| 439 | const requiredInputKeys = Array.isArray(st.typing?.requiredInputKeys) | 466 | const requiredInputKeys = typing.requiredInputKeys ?? extractTopLevelRequiredKeys(inputSchema); |
| 440 | ? st.typing!.requiredInputKeys!.filter((v): v is string => typeof v === "string") | 467 | const previewInputKeys = typing.previewInputKeys ?? buildPreviewKeys(inputSchema); |
| 441 | : extractTopLevelRequiredKeys(inputSchema); | 468 | const inputHint = typing.inputHint?.trim(); |
| 442 | const previewInputKeys = Array.isArray(st.typing?.previewInputKeys) | 469 | const outputHint = typing.outputHint?.trim(); |
| 443 | ? st.typing!.previewInputKeys!.filter((v): v is string => typeof v === "string") | ||
| 444 | : buildPreviewKeys(inputSchema); | ||
| 445 | 470 | ||
| 446 | const displayInput = typeof st.typing?.inputHint === "string" && st.typing.inputHint.trim().length > 0 | 471 | const displayInput = inputHint && inputHint.length > 0 |
| 447 | ? st.typing.inputHint.trim() | 472 | ? inputHint |
| 448 | : (Object.keys(inputSchema).length === 0 | 473 | : (Object.keys(inputSchema).length === 0 |
| 449 | ? "{}" | 474 | ? "{}" |
| 450 | : normalizeHint(jsonSchemaTypeHintFallback(inputSchema))); | 475 | : normalizeHint(jsonSchemaTypeHintFallback(inputSchema))); |
| 451 | 476 | ||
| 452 | const displayOutput = typeof st.typing?.outputHint === "string" && st.typing.outputHint.trim().length > 0 | 477 | const displayOutput = outputHint && outputHint.length > 0 |
| 453 | ? st.typing.outputHint.trim() | 478 | ? outputHint |
| 454 | : (Object.keys(outputSchema).length === 0 | 479 | : (Object.keys(outputSchema).length === 0 |
| 455 | ? "unknown" | 480 | ? "unknown" |
| 456 | : normalizeHint(jsonSchemaTypeHintFallback(outputSchema))); | 481 | : normalizeHint(jsonSchemaTypeHintFallback(outputSchema))); |
| @@ -525,6 +550,11 @@ async function buildWorkspaceToolRegistry( · 68 unmodified lines | |||
| 525 | buildId, | 550 | buildId, |
| 526 | }); | 551 | }); |
| 527 | 552 | ||
| 553 | await ctx.runMutation(internal.toolRegistry.pruneBuilds, { | ||
| 554 | workspaceId: args.workspaceId, | ||
| 555 | maxRetainedBuilds: 2, | ||
| 556 | }); | ||
| 557 | |||
| 528 | return { buildId }; | 558 | return { buildId }; |
| 529 | } | 559 | } |
| 530 | 560 | ||
| @@ -553,8 +583,9 @@ export async function getWorkspaceTools( · 22 unmodified lines | |||
| 553 | if (source.type !== "mcp") { | 583 | if (source.type !== "mcp") { |
| 554 | return false; | 584 | return false; |
| 555 | } | 585 | } |
| 556 | const auth = asPayload(source.config.auth); | 586 | |
| 557 | return auth?.mode === "actor"; | 587 | const auth = actorScopedMcpAuthSchema.safeParse(source.config.auth); |
| 588 | return auth.success; | ||
| 558 | }); | 589 | }); |
| 559 | const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource; | 590 | const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource; |
| 560 | const skipCacheWrite = hasActorScopedMcpSource; | 591 | const skipCacheWrite = hasActorScopedMcpSource; |
| @@ -670,6 +701,7 @@ export async function getWorkspaceTools( · 109 unmodified lines | |||
| 670 | timedOut: boolean; | 701 | timedOut: boolean; |
| 671 | sourceName: string; | 702 | sourceName: string; |
| 672 | openApiDts?: string; | 703 | openApiDts?: string; |
| 704 | openApiSourceKey?: string; | ||
| 673 | }>((resolve) => { | 705 | }>((resolve) => { |
| 674 | timer = setTimeout(() => { | 706 | timer = setTimeout(() => { |
| 675 | resolve({ | 707 | resolve({ |
| @@ -678,6 +710,7 @@ export async function getWorkspaceTools( · 2 unmodified lines | |||
| 678 | timedOut: true, | 710 | timedOut: true, |
| 679 | sourceName: config.name, | 711 | sourceName: config.name, |
| 680 | openApiDts: undefined, | 712 | openApiDts: undefined, |
| 713 | openApiSourceKey: config.type === "openapi" ? (config.sourceKey ?? `openapi:${config.name}`) : undefined, | ||
| 681 | }); | 714 | }); |
| 682 | }, sourceTimeoutMs); | 715 | }, sourceTimeoutMs); |
| 683 | }); | 716 | }); |
| @@ -739,7 +772,8 @@ export async function getWorkspaceTools( · 55 unmodified lines | |||
| 739 | const openApiDtsBySource: Record<string, string> = {}; | 772 | const openApiDtsBySource: Record<string, string> = {}; |
| 740 | for (const loaded of loadedSources) { | 773 | for (const loaded of loadedSources) { |
| 741 | if (loaded.openApiDts && loaded.openApiDts.trim().length > 0) { | 774 | if (loaded.openApiDts && loaded.openApiDts.trim().length > 0) { |
| 742 | openApiDtsBySource[`openapi:${loaded.sourceName}`] = loaded.openApiDts; | 775 | const sourceKey = loaded.openApiSourceKey ?? `openapi:${loaded.sourceName}`; |
| 776 | openApiDtsBySource[sourceKey] = loaded.openApiDts; | ||
| 743 | } | 777 | } |
| 744 | } | 778 | } |
| 745 | const typeBundle = buildWorkspaceTypeBundle({ | 779 | const typeBundle = buildWorkspaceTypeBundle({ |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { Result } from "better-result"; | 1 | import { Result } from "better-result"; |
| 2 | import { api } from "@executor/database/convex/_generated/api"; | 2 | import { api } from "@executor/database/convex/_generated/api"; |
| 3 | import { ConvexClient, ConvexHttpClient } from "convex/browser"; | 3 | import { ConvexClient, ConvexHttpClient } from "convex/browser"; |
| 4 | import { z } from "zod"; | ||
| 4 | import type { | 5 | import type { |
| 5 | BridgeEntrypointContext, | 6 | BridgeEntrypointContext, |
| 6 | BridgeProps, | 7 | BridgeProps, |
| @@ -10,33 +11,41 @@ import type { · 3 unmodified lines | |||
| 10 | 11 | ||
| 11 | const APPROVAL_SUBSCRIPTION_TIMEOUT_MS = 10 * 60 * 1000; | 12 | const APPROVAL_SUBSCRIPTION_TIMEOUT_MS = 10 * 60 * 1000; |
| 12 | 13 | ||
| 13 | function asObject(value: unknown): Record<string, unknown> { | 14 | const bridgePropsSchema: z.ZodType<BridgeProps> = z.object({ |
| 14 | if (value && typeof value === "object" && !Array.isArray(value)) { | 15 | callbackConvexUrl: z.string(), |
| 15 | return value as Record<string, unknown>; | 16 | callbackInternalSecret: z.string(), |
| 16 | } | 17 | taskId: z.string(), |
| 17 | return {}; | 18 | }); |
| 18 | } | 19 | |
| 19 | 20 | const toolCallResultSchema: z.ZodType<ToolCallResult> = z.union([ | |
| 20 | function isBridgeProps(value: unknown): value is BridgeProps { | 21 | z.object({ ok: z.literal(true), value: z.unknown().optional() }), |
| 21 | const props = asObject(value); | 22 | z.object({ |
| 22 | return ( | 23 | ok: z.literal(false), |
| 23 | typeof props.callbackConvexUrl === "string" | 24 | kind: z.literal("pending"), |
| 24 | && typeof props.callbackInternalSecret === "string" | 25 | approvalId: z.string(), |
| 25 | && typeof props.taskId === "string" | 26 | error: z.string().optional(), |
| 26 | ); | 27 | retryAfterMs: z.number().optional(), |
| 28 | }), | ||
| 29 | z.object({ | ||
| 30 | ok: z.literal(false), | ||
| 31 | kind: z.enum(["denied", "failed"]), | ||
| 32 | error: z.string().optional(), | ||
| 33 | approvalId: z.string().optional(), | ||
| 34 | retryAfterMs: z.number().optional(), | ||
| 35 | }), | ||
| 36 | ]); | ||
| 37 | |||
| 38 | const recordSchema = z.record(z.unknown()); | ||
| 39 | |||
| 40 | function hasToolBridgeExport( | ||
| 41 | value: Record<string, unknown>, | ||
| 42 | ): value is { ToolBridge: WorkerEntrypointExports["ToolBridge"] } { | ||
| 43 | return typeof value.ToolBridge === "function"; | ||
| 27 | } | 44 | } |
| 28 | 45 | ||
| 29 | function isToolCallResult(value: unknown): value is ToolCallResult { | 46 | function asObject(value: unknown): Record<string, unknown> { |
| 30 | const record = asObject(value); | 47 | const parsed = recordSchema.safeParse(value); |
| 31 | if (record.ok === true) { | 48 | return parsed.success ? parsed.data : {}; |
| 32 | return true; | ||
| 33 | } | ||
| 34 | |||
| 35 | if (record.ok === false) { | ||
| 36 | return record.kind === "pending" || record.kind === "denied" || record.kind === "failed"; | ||
| 37 | } | ||
| 38 | |||
| 39 | return false; | ||
| 40 | } | 49 | } |
| 41 | 50 | ||
| 42 | export function getBridgePropsFromContext( | 51 | export function getBridgePropsFromContext( |
| @@ -47,11 +56,12 @@ export function getBridgePropsFromContext( · 4 unmodified lines | |||
| 47 | throw new Error("WorkerEntrypoint context is unavailable"); | 56 | throw new Error("WorkerEntrypoint context is unavailable"); |
| 48 | } | 57 | } |
| 49 | 58 | ||
| 50 | if (!isBridgeProps(context.props)) { | 59 | const parsedProps = bridgePropsSchema.safeParse(context.props); |
| 60 | if (!parsedProps.success) { | ||
| 51 | throw new Error("ToolBridge props are missing or invalid"); | 61 | throw new Error("ToolBridge props are missing or invalid"); |
| 52 | } | 62 | } |
| 53 | 63 | ||
| 54 | return context.props; | 64 | return parsedProps.data; |
| 55 | } | 65 | } |
| 56 | 66 | ||
| 57 | export function getEntrypointExports(ctx: ExecutionContext): WorkerEntrypointExports { | 67 | export function getEntrypointExports(ctx: ExecutionContext): WorkerEntrypointExports { |
| @@ -63,11 +73,11 @@ export function getEntrypointExports(ctx: ExecutionContext): WorkerEntrypointExp · 5 unmodified lines | |||
| 63 | } | 73 | } |
| 64 | 74 | ||
| 65 | const exportsObject = asObject(exportsValue); | 75 | const exportsObject = asObject(exportsValue); |
| 66 | if (typeof exportsObject.ToolBridge !== "function") { | 76 | if (!hasToolBridgeExport(exportsObject)) { |
| 67 | throw new Error("Execution context ToolBridge export is unavailable"); | 77 | throw new Error("Execution context ToolBridge export is unavailable"); |
| 68 | } | 78 | } |
| 69 | 79 | ||
| 70 | return { ToolBridge: exportsObject.ToolBridge as WorkerEntrypointExports["ToolBridge"] }; | 80 | return { ToolBridge: exportsObject.ToolBridge }; |
| 71 | } | 81 | } |
| 72 | 82 | ||
| 73 | function createConvexClient(callbackConvexUrl: string): ConvexHttpClient { | 83 | function createConvexClient(callbackConvexUrl: string): ConvexHttpClient { |
| @@ -157,16 +167,13 @@ export async function callToolWithBridge( · 83 unmodified lines | |||
| 157 | return { ok: false, kind: "failed", error: `Tool callback failed: ${message}` }; | 167 | return { ok: false, kind: "failed", error: `Tool callback failed: ${message}` }; |
| 158 | } | 168 | } |
| 159 | 169 | ||
| 160 | const result = response.value; | 170 | const parsedResult = toolCallResultSchema.safeParse(response.value); |
| 161 | if (!isToolCallResult(result)) { | 171 | if (!parsedResult.success) { |
| 162 | return { ok: false, kind: "failed", error: "Tool callback returned invalid result payload" }; | 172 | return { ok: false, kind: "failed", error: "Tool callback returned invalid result payload" }; |
| 163 | } | 173 | } |
| 174 | const result = parsedResult.data; | ||
| 164 | 175 | ||
| 165 | if (!result.ok && result.kind === "pending") { | 176 | if (!result.ok && result.kind === "pending") { |
| 166 | if (!result.approvalId) { | ||
| 167 | return { ok: false, kind: "failed", error: "Approval pending without approvalId" }; | ||
| 168 | } | ||
| 169 | |||
| 170 | const approvalId = result.approvalId; | 177 | const approvalId = result.approvalId; |
| 171 | const wait = await Result.tryPromise(() => waitForApprovalUpdate(props, approvalId)); | 178 | const wait = await Result.tryPromise(() => waitForApprovalUpdate(props, approvalId)); |
| 172 | if (wait.isErr()) { | 179 | if (wait.isErr()) { |
| @@ -1,19 +1,31 @@ | |||
| 1 | import { Result } from "better-result"; | 1 | import { z } from "zod"; |
| 2 | import type { RunRequest } from "./types"; | 2 | import type { RunRequest } from "./types"; |
| 3 | 3 | ||
| 4 | const runRequestSchema: z.ZodType<RunRequest> = z.object({ | ||
| 5 | taskId: z.string().min(1), | ||
| 6 | code: z.string().min(1), | ||
| 7 | timeoutMs: z.number().int().positive().optional(), | ||
| 8 | callback: z.object({ | ||
| 9 | convexUrl: z.string().min(1), | ||
| 10 | internalSecret: z.string().min(1), | ||
| 11 | }), | ||
| 12 | }); | ||
| 13 | |||
| 4 | export async function parseRunRequest(request: Request): Promise<RunRequest | Response> { | 14 | export async function parseRunRequest(request: Request): Promise<RunRequest | Response> { |
| 5 | const parsed = await Result.tryPromise(() => request.json() as Promise<RunRequest>); | 15 | let rawBody: unknown; |
| 6 | if (parsed.isErr()) { | 16 | try { |
| 17 | rawBody = await request.json(); | ||
| 18 | } catch { | ||
| 7 | return Response.json({ error: "Invalid JSON body" }, { status: 400 }); | 19 | return Response.json({ error: "Invalid JSON body" }, { status: 400 }); |
| 8 | } | 20 | } |
| 9 | 21 | ||
| 10 | const body = parsed.value; | 22 | const body = runRequestSchema.safeParse(rawBody); |
| 11 | if (!body.taskId || !body.code || !body.callback?.convexUrl || !body.callback?.internalSecret) { | 23 | if (!body.success) { |
| 12 | return Response.json( | 24 | return Response.json( |
| 13 | { error: "Missing required fields: taskId, code, callback.convexUrl, callback.internalSecret" }, | 25 | { error: "Missing required fields: taskId, code, callback.convexUrl, callback.internalSecret" }, |
| 14 | { status: 400 }, | 26 | { status: 400 }, |
| 15 | ); | 27 | ); |
| 16 | } | 28 | } |
| 17 | 29 | ||
| 18 | return body; | 30 | return body.data; |
| 19 | } | 31 | } |
| @@ -1,7 +1,14 @@ | |||
| 1 | import { Result } from "better-result"; | 1 | import { z } from "zod"; |
| 2 | import type { Env, RunRequest, RunResult } from "./types"; | 2 | import type { Env, RunRequest, RunResult } from "./types"; |
| 3 | import { getEntrypointExports } from "./bridge"; | 3 | import { getEntrypointExports } from "./bridge"; |
| 4 | 4 | ||
| 5 | const runResultSchema: z.ZodType<RunResult> = z.object({ | ||
| 6 | status: z.enum(["completed", "failed", "timed_out", "denied"]), | ||
| 7 | result: z.unknown().optional(), | ||
| 8 | error: z.string().optional(), | ||
| 9 | exitCode: z.number().optional(), | ||
| 10 | }); | ||
| 11 | |||
| 5 | /** | 12 | /** |
| 6 | * Build the user code module. The code is wrapped in an exported async | 13 | * Build the user code module. The code is wrapped in an exported async |
| 7 | * function `run(tools, console)` so the harness can call it with controlled | 14 | * function `run(tools, console)` so the harness can call it with controlled |
| @@ -51,32 +58,42 @@ export async function executeSandboxRun( · 43 unmodified lines | |||
| 51 | const controller = new AbortController(); | 58 | const controller = new AbortController(); |
| 52 | const timer = setTimeout(() => controller.abort(), timeoutMs); | 59 | const timer = setTimeout(() => controller.abort(), timeoutMs); |
| 53 | 60 | ||
| 54 | const response = await Result.tryPromise(() => | 61 | let response: Response; |
| 55 | entrypoint.fetch("http://sandbox.internal/run", { | 62 | try { |
| 63 | response = await entrypoint.fetch("http://sandbox.internal/run", { | ||
| 56 | method: "POST", | 64 | method: "POST", |
| 57 | signal: controller.signal, | 65 | signal: controller.signal, |
| 58 | }), | 66 | }); |
| 59 | ); | 67 | } catch (error) { |
| 60 | 68 | clearTimeout(timer); | |
| 61 | clearTimeout(timer); | 69 | if (error instanceof DOMException && error.name === "AbortError") { |
| 62 | |||
| 63 | if (response.isErr()) { | ||
| 64 | const cause = response.error.cause; | ||
| 65 | if (cause instanceof DOMException && cause.name === "AbortError") { | ||
| 66 | return { | 70 | return { |
| 67 | status: "timed_out", | 71 | status: "timed_out", |
| 68 | error: `Execution timed out after ${timeoutMs}ms`, | 72 | error: `Execution timed out after ${timeoutMs}ms`, |
| 69 | }; | 73 | }; |
| 70 | } | 74 | } |
| 71 | throw cause; | 75 | throw error; |
| 72 | } | 76 | } |
| 73 | 77 | ||
| 74 | const body = await Result.tryPromise(() => response.value.json() as Promise<RunResult>); | 78 | clearTimeout(timer); |
| 75 | if (body.isErr()) { | 79 | |
| 80 | let body: unknown; | ||
| 81 | try { | ||
| 82 | body = await response.json(); | ||
| 83 | } catch { | ||
| 76 | return { | 84 | return { |
| 77 | status: "failed", | 85 | status: "failed", |
| 78 | error: "Sandbox isolate returned invalid JSON", | 86 | error: "Sandbox isolate returned invalid JSON", |
| 79 | }; | 87 | }; |
| 80 | } | 88 | } |
| 81 | return body.value; | 89 | |
| 90 | const parsedBody = runResultSchema.safeParse(body); | ||
| 91 | if (!parsedBody.success) { | ||
| 92 | return { | ||
| 93 | status: "failed", | ||
| 94 | error: "Sandbox isolate returned invalid response payload", | ||
| 95 | }; | ||
| 96 | } | ||
| 97 | |||
| 98 | return parsedBody.data; | ||
| 82 | } | 99 | } |
| @@ -28,7 +28,7 @@ export interface EntrypointStub { · 27 unmodified lines | |||
| 28 | export interface RunRequest { | 28 | export interface RunRequest { |
| 29 | taskId: string; | 29 | taskId: string; |
| 30 | code: string; | 30 | code: string; |
| 31 | timeoutMs: number; | 31 | timeoutMs?: number; |
| 32 | callback: { | 32 | callback: { |
| 33 | convexUrl: string; | 33 | convexUrl: string; |
| 34 | internalSecret: string; | 34 | internalSecret: string; |
| @@ -42,14 +42,25 @@ export interface RunResult { · 7 unmodified lines | |||
| 42 | exitCode?: number; | 42 | exitCode?: number; |
| 43 | } | 43 | } |
| 44 | 44 | ||
| 45 | export interface ToolCallResult { | 45 | export type ToolCallResult = |
| 46 | ok: true | false; | 46 | | { |
| 47 | value?: unknown; | 47 | ok: true; |
| 48 | error?: string; | 48 | value?: unknown; |
| 49 | kind?: "pending" | "denied" | "failed"; | 49 | } |
| 50 | approvalId?: string; | 50 | | { |
| 51 | retryAfterMs?: number; | 51 | ok: false; |
| 52 | } | 52 | kind: "pending"; |
| 53 | approvalId: string; | ||
| 54 | error?: string; | ||
| 55 | retryAfterMs?: number; | ||
| 56 | } | ||
| 57 | | { | ||
| 58 | ok: false; | ||
| 59 | kind: "denied" | "failed"; | ||
| 60 | error?: string; | ||
| 61 | approvalId?: string; | ||
| 62 | retryAfterMs?: number; | ||
| 63 | }; | ||
| 53 | 64 | ||
| 54 | export interface BridgeProps { | 65 | export interface BridgeProps { |
| 55 | callbackConvexUrl: string; | 66 | callbackConvexUrl: string; |
| @@ -1,18 +1,18 @@ | |||
| 1 | { | 1 | { |
| 2 | "$schema": "https://opencode.ai/config.json", | 2 | "$schema": "https://opencode.ai/config.json", |
| 3 | "mcp":{ | 3 | "mcp": { |
| 4 | "answeroverflow": { | 4 | "answeroverflow": { |
| 5 | "type": "remote", | 5 | "type": "remote", |
| 6 | "url": "https://www.answeroverflow.com/mcp", | 6 | "url": "https://www.answeroverflow.com/mcp", |
| 7 | "enabled": true | 7 | "enabled": true |
| 8 | }, | 8 | }, |
| 9 | "oracle": { | 9 | "oracle": { |
| 10 | "type": "local", | 10 | "type": "local", |
| 11 | "command": ["npx", "-y", "@steipete/oracle", "oracle-mcp"], | 11 | "command": ["npx", "-y", "@steipete/oracle", "oracle-mcp"], |
| 12 | "enabled": true | 12 | "enabled": true |
| 13 | } | 13 | } |
| 14 | }, | 14 | }, |
| 15 | "agent":{ | 15 | "agent": { |
| 16 | "quick-build": { | 16 | "quick-build": { |
| 17 | "mode": "primary", | 17 | "mode": "primary", |
| 18 | "description": "A smaller context winder but extremely fast at working", | 18 | "description": "A smaller context winder but extremely fast at working", |
| @@ -22,6 +22,16 @@ · 3 unmodified lines | |||
| 22 | "edit": true, | 22 | "edit": true, |
| 23 | "bash": true | 23 | "bash": true |
| 24 | } | 24 | } |
| 25 | }, | ||
| 26 | "quick-build-subagent": { | ||
| 27 | "mode": "subagent", | ||
| 28 | "description": "A smaller context winder but extremely fast at working", | ||
| 29 | "model": "openai/gpt-5.3-codex-spark", | ||
| 30 | "tools": { | ||
| 31 | "write": true, | ||
| 32 | "edit": true, | ||
| 33 | "bash": true | ||
| 34 | } | ||
| 25 | } | 35 | } |
| 26 | } | 36 | } |
| 27 | } | 37 | } |