Unified · Split
| @@ -1,5 +1,10 @@ | ||
| 1 | 1 | import type { NextRequest } from "next/server"; |
| 2 | 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 | 9 | function toSiteUrl(convexUrl?: string): string | null { |
| 5 | 10 | if (!convexUrl) { |
| @@ -48,9 +53,9 @@ export async function POST(request: NextRequest) { · 42 unmodified lines | ||
| 48 | 53 | |
| 49 | 54 | let body: { actorId?: string } = {}; |
| 50 | 55 | try { |
| 51 | − const parsed = await request.json() as { actorId?: unknown }; | |
| 52 | − if (typeof parsed.actorId === "string" && parsed.actorId.trim().length > 0) { | |
| 53 | − body.actorId = parsed.actorId.trim(); | |
| 56 | + const parsed = anonymousTokenRequestSchema.safeParse(await request.json()); | |
| 57 | + if (parsed.success && parsed.data.actorId) { | |
| 58 | + body.actorId = parsed.data.actorId; | |
| 54 | 59 | } |
| 55 | 60 | } catch { |
| 56 | 61 | body = {}; |
| @@ -67,7 +72,7 @@ export async function POST(request: NextRequest) { · 10 unmodified lines | ||
| 67 | 72 | }); |
| 68 | 73 | |
| 69 | 74 | const text = await response.text(); |
| 70 | − let payload: unknown = null; | |
| 75 | + let payload: unknown; | |
| 71 | 76 | try { |
| 72 | 77 | payload = JSON.parse(text); |
| 73 | 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 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | import { Result } from "better-result"; |
| 3 | +import { z } from "zod"; | |
| 3 | 4 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 4 | 5 | import type { |
| 5 | 6 | OAuthClientInformationMixed, |
| @@ -28,6 +29,44 @@ export type McpOAuthPopupResult = { · 22 unmodified lines | ||
| 28 | 29 | const COOKIE_PREFIX = "executor_mcp_oauth_"; |
| 29 | 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 | 70 | export function buildPendingCookieName(state: string): string { |
| 32 | 71 | return `${COOKIE_PREFIX}${state}`; |
| 33 | 72 | } |
| @@ -37,67 +76,41 @@ export function createOAuthState(): string { · 3 unmodified lines | ||
| 37 | 76 | } |
| 38 | 77 | |
| 39 | 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 | 82 | export function decodePendingCookieValue(raw: string): McpOAuthPending | null { |
| 44 | − const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8")); | |
| 45 | − if (!decodedResult.isOk()) { | |
| 46 | − return null; | |
| 47 | − } | |
| 48 | − | |
| 49 | − const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPending>); | |
| 50 | − if (!parsedResult.isOk()) { | |
| 83 | + const decoded = decodeCookieJson(raw); | |
| 84 | + if (!decoded) { | |
| 51 | 85 | return null; |
| 52 | 86 | } |
| 53 | 87 | |
| 54 | − const parsed = parsedResult.value; | |
| 55 | − if ( | |
| 56 | − typeof parsed.state !== "string" | |
| 57 | − || typeof parsed.sourceUrl !== "string" | |
| 58 | − || typeof parsed.redirectUrl !== "string" | |
| 59 | − ) { | |
| 60 | − return null; | |
| 88 | + const versionedResult = versionedPendingSchema.safeParse(decoded); | |
| 89 | + if (versionedResult.success) { | |
| 90 | + return versionedResult.data.pending; | |
| 61 | 91 | } |
| 62 | 92 | |
| 63 | − return { | |
| 64 | − state: parsed.state, | |
| 65 | − sourceUrl: parsed.sourceUrl, | |
| 66 | − redirectUrl: parsed.redirectUrl, | |
| 67 | − ...(typeof parsed.codeVerifier === "string" ? { codeVerifier: parsed.codeVerifier } : {}), | |
| 68 | − ...(parsed.clientInformation ? { clientInformation: parsed.clientInformation } : {}), | |
| 69 | − }; | |
| 93 | + const legacyResult = pendingSchema.safeParse(decoded); | |
| 94 | + return legacyResult.success ? legacyResult.data : null; | |
| 70 | 95 | } |
| 71 | 96 | |
| 72 | 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 | 101 | export function decodePopupResultCookieValue(raw: string): McpOAuthPopupResult | null { |
| 77 | − const decodedResult = Result.try(() => Buffer.from(raw, "base64url").toString("utf8")); | |
| 78 | − if (!decodedResult.isOk()) { | |
| 102 | + const decoded = decodeCookieJson(raw); | |
| 103 | + if (!decoded) { | |
| 79 | 104 | return null; |
| 80 | 105 | } |
| 81 | 106 | |
| 82 | − const parsedResult = Result.try(() => JSON.parse(decodedResult.value) as Partial<McpOAuthPopupResult>); | |
| 83 | − if (!parsedResult.isOk()) { | |
| 84 | − return null; | |
| 85 | − } | |
| 86 | − | |
| 87 | − const parsed = parsedResult.value; | |
| 88 | − if (typeof parsed.ok !== "boolean") { | |
| 89 | − return null; | |
| 107 | + const versionedResult = versionedPopupResultSchema.safeParse(decoded); | |
| 108 | + if (versionedResult.success) { | |
| 109 | + return versionedResult.data.result; | |
| 90 | 110 | } |
| 91 | 111 | |
| 92 | − return { | |
| 93 | − ok: parsed.ok, | |
| 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 | − }; | |
| 112 | + const legacyResult = popupResultSchema.safeParse(decoded); | |
| 113 | + return legacyResult.success ? legacyResult.data : null; | |
| 101 | 114 | } |
| 102 | 115 | |
| 103 | 116 | export class McpPopupOAuthProvider implements OAuthClientProvider { |
| @@ -1,4 +1,6 @@ | ||
| 1 | +import { Result } from "better-result"; | |
| 1 | 2 | import { parse as parseYaml } from "yaml"; |
| 3 | +import { z } from "zod"; | |
| 2 | 4 | import type { CredentialScope, SourceAuthType } from "@/lib/types"; |
| 3 | 5 | |
| 4 | 6 | type SupportedAuthType = Exclude<SourceAuthType, "none" | "mixed">; |
| @@ -15,11 +17,36 @@ type OpenApiInspectionResult = { · 10 unmodified lines | ||
| 15 | 17 | inferredAuth: InferredSpecAuth; |
| 16 | 18 | }; |
| 17 | 19 | |
| 18 | −function asRecord(value: unknown): Record<string, unknown> { | |
| 19 | − if (!value || typeof value !== "object" || Array.isArray(value)) { | |
| 20 | − return {}; | |
| 20 | +const recordSchema = z.record(z.unknown()); | |
| 21 | + | |
| 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 | 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 | 54 | const loweredUrl = sourceUrl.toLowerCase(); |
| 28 | 55 | const preferJson = loweredContentType.includes("json") || loweredUrl.endsWith(".json"); |
| 29 | 56 | |
| 30 | − const tryJson = () => asRecord(JSON.parse(raw)); | |
| 31 | − const tryYaml = () => asRecord(parseYaml(raw)); | |
| 32 | − | |
| 33 | − const parsed = preferJson | |
| 34 | − ? (() => { | |
| 35 | − try { | |
| 36 | − return tryJson(); | |
| 37 | − } catch { | |
| 38 | − return tryYaml(); | |
| 39 | − } | |
| 40 | − })() | |
| 41 | − : (() => { | |
| 42 | − try { | |
| 43 | − return tryYaml(); | |
| 44 | − } catch { | |
| 45 | − return tryJson(); | |
| 46 | − } | |
| 47 | − })(); | |
| 48 | − | |
| 49 | − if (Object.keys(parsed).length === 0) { | |
| 57 | + const primary = preferJson | |
| 58 | + ? parseObjectFromText(raw, "json") | |
| 59 | + : parseObjectFromText(raw, "yaml"); | |
| 60 | + const fallback = preferJson | |
| 61 | + ? parseObjectFromText(raw, "yaml") | |
| 62 | + : parseObjectFromText(raw, "json"); | |
| 63 | + const parsed = primary ?? fallback; | |
| 64 | + | |
| 65 | + if (!parsed) { | |
| 50 | 66 | throw new Error("Spec payload is empty or not an object"); |
| 51 | 67 | } |
| 52 | 68 | |
| 53 | 69 | return parsed; |
| 54 | 70 | } |
| 55 | 71 | |
| 56 | −function normalizeAuthScheme(scheme: Record<string, unknown>): { | |
| 72 | +function normalizeAuthScheme(scheme: unknown): { | |
| 57 | 73 | type: SupportedAuthType; |
| 58 | 74 | header?: string; |
| 59 | 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 | 83 | if (type === "http") { |
| 63 | − const httpScheme = String(scheme.scheme ?? "").toLowerCase(); | |
| 84 | + const httpScheme = (parsedScheme.data.scheme ?? "").toLowerCase(); | |
| 64 | 85 | if (httpScheme === "bearer") { |
| 65 | 86 | return { type: "bearer" }; |
| 66 | 87 | } |
| @@ -71,8 +92,8 @@ function normalizeAuthScheme(scheme: Record<string, unknown>): { · 4 unmodified lines | ||
| 71 | 92 | } |
| 72 | 93 | |
| 73 | 94 | if (type === "apikey") { |
| 74 | − const location = String(scheme.in ?? "").toLowerCase(); | |
| 75 | − const header = typeof scheme.name === "string" ? scheme.name.trim() : ""; | |
| 95 | + const location = (parsedScheme.data.in ?? "").toLowerCase(); | |
| 96 | + const header = (parsedScheme.data.name ?? "").trim(); | |
| 76 | 97 | if (location === "header" && header.length > 0) { |
| 77 | 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 | 110 | function inferSecuritySchemaAuth(spec: Record<string, unknown>): InferredSpecAuth { |
| 90 | − const components = asRecord(spec.components); | |
| 91 | − const securitySchemes = asRecord(components.securitySchemes); | |
| 111 | + const components = toRecordValue(spec.components); | |
| 112 | + const securitySchemes = toRecordValue(components.securitySchemes); | |
| 92 | 113 | const schemeNames = Object.keys(securitySchemes); |
| 93 | 114 | if (schemeNames.length === 0) { |
| 94 | 115 | return { type: "none", inferred: true }; |
| 95 | 116 | } |
| 96 | 117 | |
| 97 | 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 | 124 | const referencedSchemeNames = globalSecurity.flatMap((entry) => Object.keys(entry)); |
| 101 | 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 | 127 | : schemeNames; |
| 104 | 128 | |
| 105 | 129 | const normalized = candidateNames |
| 106 | − .map((name) => normalizeAuthScheme(asRecord(securitySchemes[name]))) | |
| 130 | + .map((name) => normalizeAuthScheme(securitySchemes[name])) | |
| 107 | 131 | .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)); |
| 108 | 132 | |
| 109 | 133 | if (normalized.length === 0) { |
| @@ -1,9 +1,16 @@ | ||
| 1 | 1 | import type { Id } from "@executor/database/convex/_generated/dataModel"; |
| 2 | +import { z } from "zod"; | |
| 2 | 3 | |
| 3 | 4 | const SESSION_KEY = "executor_session_id"; |
| 4 | 5 | const ACTIVE_WORKSPACE_KEY = "executor_active_workspace_id"; |
| 5 | 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 | 14 | export function readStoredSessionId() { |
| 8 | 15 | if (typeof window === "undefined") { |
| 9 | 16 | return null; |
| @@ -17,7 +24,12 @@ export function readStoredActiveWorkspaceId() { · 7 unmodified lines | ||
| 17 | 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 | 35 | export function persistSessionId(sessionId: string) { |
| @@ -33,18 +45,50 @@ export function clearSessionStorage() { · 9 unmodified lines | ||
| 33 | 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 | 53 | const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY); |
| 38 | 54 | if (!raw) { |
| 39 | − return {} as Record<string, Id<"workspaces">>; | |
| 55 | + return {}; | |
| 40 | 56 | } |
| 41 | 57 | |
| 58 | + let parsedJson: unknown; | |
| 42 | 59 | try { |
| 43 | − const parsed = JSON.parse(raw) as Record<string, Id<"workspaces">>; | |
| 44 | − return parsed ?? {}; | |
| 60 | + parsedJson = JSON.parse(raw); | |
| 45 | 61 | } catch { |
| 46 | 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 | 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 | 5 | export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_out" | "denied"; |
| 6 | 6 | export type ApprovalStatus = "pending" | "approved" | "denied"; |
| 7 | 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 | 12 | export type CredentialScope = "workspace" | "actor"; |
| 9 | 13 | export type CredentialProvider = "local-convex" | "workos-vault"; |
| 14 | +export type OwnerScopeType = "organization" | "workspace"; | |
| 10 | 15 | export type ToolApprovalMode = "auto" | "required"; |
| 11 | 16 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; |
| 12 | 17 | |
| @@ -65,9 +70,17 @@ export interface TaskEventRecord { · 52 unmodified lines | ||
| 65 | 70 | |
| 66 | 71 | export interface AccessPolicyRecord { |
| 67 | 72 | id: string; |
| 68 | − workspaceId: string; | |
| 73 | + scopeType?: PolicyScopeType; | |
| 74 | + organizationId?: string; | |
| 75 | + workspaceId?: string; | |
| 76 | + targetActorId?: string; | |
| 69 | 77 | actorId?: string; |
| 70 | 78 | clientId?: string; |
| 79 | + resourceType?: "tool_path"; | |
| 80 | + resourcePattern?: string; | |
| 81 | + matchType?: PolicyMatchType; | |
| 82 | + effect?: PolicyEffect; | |
| 83 | + approvalMode?: PolicyApprovalMode; | |
| 71 | 84 | toolPathPattern: string; |
| 72 | 85 | decision: PolicyDecision; |
| 73 | 86 | priority: number; |
| @@ -78,7 +91,9 @@ export interface AccessPolicyRecord { · 4 unmodified lines | ||
| 78 | 91 | export interface CredentialRecord { |
| 79 | 92 | id: string; |
| 80 | 93 | bindingId?: string; |
| 81 | − workspaceId: string; | |
| 94 | + ownerScopeType?: OwnerScopeType; | |
| 95 | + organizationId?: string; | |
| 96 | + workspaceId?: string; | |
| 82 | 97 | sourceKey: string; |
| 83 | 98 | scope: CredentialScope; |
| 84 | 99 | actorId?: string; |
| @@ -92,10 +107,14 @@ export interface CredentialRecord { · 7 unmodified lines | ||
| 92 | 107 | |
| 93 | 108 | export interface ToolSourceRecord { |
| 94 | 109 | id: string; |
| 95 | − workspaceId: string; | |
| 110 | + ownerScopeType?: OwnerScopeType; | |
| 111 | + organizationId?: string; | |
| 112 | + workspaceId?: string; | |
| 96 | 113 | name: string; |
| 97 | 114 | type: ToolSourceType; |
| 98 | 115 | config: Record<string, unknown>; |
| 116 | + specHash?: string; | |
| 117 | + authFingerprint?: string; | |
| 99 | 118 | enabled: boolean; |
| 100 | 119 | createdAt: number; |
| 101 | 120 | updatedAt: number; |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | −import { resolveCredentialPayload } from "./credential-providers"; | |
| 2 | +import { resolveCredentialPayload, resolveCredentialPayloadResult } from "./credential-providers"; | |
| 3 | 3 | |
| 4 | 4 | test("local provider returns stored payload", async () => { |
| 5 | 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 | 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 | 66 | test("WorkOS Vault provider gives actionable error on missing reference", async () => { |
| 50 | 67 | await expect( |
| 51 | 68 | resolveCredentialPayload({ |
| @@ -54,3 +71,15 @@ test("WorkOS Vault provider gives actionable error on missing reference", async · 2 unmodified lines | ||
| 54 | 71 | }), |
| 55 | 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 | 1 | "use node"; |
| 2 | 2 | |
| 3 | −import { WorkOS } from "@workos-inc/node"; | |
| 4 | 3 | import { Result } from "better-result"; |
| 5 | 4 | import { z } from "zod"; |
| 5 | +import { | |
| 6 | + createWorkosClient, | |
| 7 | + parseWorkosVaultReference, | |
| 8 | + withWorkosVaultRetryResult, | |
| 9 | +} from "./credentials/workos-vault"; | |
| 6 | 10 | import type { CredentialProvider, CredentialRecord } from "./types"; |
| 7 | −import { asRecord } from "./utils"; | |
| 8 | 11 | |
| 9 | 12 | type CredentialPayload = Record<string, unknown>; |
| 10 | 13 | |
| @@ -18,67 +21,31 @@ type VaultObjectReader = (input: VaultReadInput) => Promise<string>; · 7 unmodified lines | ||
| 18 | 21 | type ProviderResolver = ( |
| 19 | 22 | record: Pick<CredentialRecord, "provider" | "secretJson">, |
| 20 | 23 | readVaultObject: VaultObjectReader, |
| 21 | −) => Promise<CredentialPayload | null>; | |
| 24 | +) => Promise<Result<CredentialPayload | null, Error>>; | |
| 22 | 25 | |
| 23 | −const workosVaultSecretConfigSchema = z.object({ | |
| 24 | − objectId: z.string().optional(), | |
| 25 | − id: z.string().optional(), | |
| 26 | − apiKey: z.string().optional(), | |
| 27 | −}); | |
| 26 | +const recordSchema = z.record(z.unknown()); | |
| 28 | 27 | |
| 29 | −function resolveWorkosApiKey(explicitApiKey?: string): string { | |
| 30 | − const candidate = | |
| 31 | − explicitApiKey?.trim() || | |
| 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; | |
| 28 | +function coerceRecord(value: unknown): Record<string, unknown> { | |
| 29 | + const parsed = recordSchema.safeParse(value); | |
| 30 | + return parsed.success ? parsed.data : {}; | |
| 40 | 31 | } |
| 41 | 32 | |
| 42 | −function isRetryableVaultError(error: unknown): boolean { | |
| 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 | −} | |
| 33 | +const envSecretKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/); | |
| 53 | 34 | |
| 54 | −async function sleep(ms: number): Promise<void> { | |
| 55 | − await new Promise((resolve) => setTimeout(resolve, ms)); | |
| 56 | −} | |
| 57 | − | |
| 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 | − } | |
| 35 | +async function defaultReadVaultObject(input: VaultReadInput): Promise<string> { | |
| 36 | + const workosResult = createWorkosClient(input.apiKey); | |
| 37 | + if (workosResult.isErr()) { | |
| 38 | + throw new Error("WorkOS Vault provider requires WORKOS_API_KEY"); | |
| 72 | 39 | } |
| 73 | 40 | |
| 74 | − throw new Error("Unreachable retry state"); | |
| 75 | −} | |
| 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 }); | |
| 41 | + const readResult = await withWorkosVaultRetryResult(async () => { | |
| 42 | + return await workosResult.value.vault.readObject({ id: input.objectId }); | |
| 81 | 43 | }); |
| 44 | + if (readResult.isErr()) { | |
| 45 | + throw readResult.error; | |
| 46 | + } | |
| 47 | + | |
| 48 | + const object = readResult.value; | |
| 82 | 49 | const value = typeof object.value === "string" ? object.value : ""; |
| 83 | 50 | if (!value.trim()) { |
| 84 | 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 | 59 | return {}; |
| 93 | 60 | } |
| 94 | 61 | |
| 95 | − const parsedJson = Result.try(() => JSON.parse(value)); | |
| 96 | − if (parsedJson.isOk()) { | |
| 97 | − const parsed = parsedJson.value; | |
| 98 | − const asObject = asRecord(parsed); | |
| 62 | + try { | |
| 63 | + const parsed = JSON.parse(value); | |
| 64 | + const asObject = coerceRecord(parsed); | |
| 99 | 65 | if (Object.keys(asObject).length > 0) { |
| 100 | 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 | 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 | 119 | async function resolveLocalConvex( |
| 108 | 120 | record: Pick<CredentialRecord, "secretJson">, |
| 109 | −): Promise<CredentialPayload | null> { | |
| 110 | − return asRecord(record.secretJson); | |
| 121 | +): Promise<Result<CredentialPayload | null, Error>> { | |
| 122 | + return Result.ok(coerceRecord(record.secretJson)); | |
| 111 | 123 | } |
| 112 | 124 | |
| 113 | 125 | async function resolveWorkosVault( |
| 114 | 126 | record: Pick<CredentialRecord, "secretJson">, |
| 115 | 127 | readVaultObject: VaultObjectReader, |
| 116 | −): Promise<CredentialPayload | null> { | |
| 117 | − const parsedConfig = workosVaultSecretConfigSchema.safeParse(asRecord(record.secretJson)); | |
| 118 | − const config = parsedConfig.success ? parsedConfig.data : {}; | |
| 119 | − const objectId = config.objectId?.trim() || config.id?.trim() || ""; | |
| 128 | +): Promise<Result<CredentialPayload | null, Error>> { | |
| 129 | + const parsedReference = parseWorkosVaultReference(coerceRecord(record.secretJson)); | |
| 130 | + const objectId = parsedReference.objectId ?? ""; | |
| 120 | 131 | |
| 121 | 132 | if (!objectId) { |
| 122 | − throw new Error( | |
| 123 | − "Encrypted credential is missing its secure reference. Re-save this credential in the dashboard.", | |
| 133 | + return Result.err( | |
| 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 }); | |
| 129 | − return parseSecretValue(raw); | |
| 139 | + const rawResult = await Result.tryPromise(async () => { | |
| 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 | 149 | const providers: Record<CredentialProvider, ProviderResolver> = { |
| @@ -138,10 +155,22 @@ export async function resolveCredentialPayload( · 5 unmodified lines | ||
| 138 | 155 | record: Pick<CredentialRecord, "provider" | "secretJson">, |
| 139 | 156 | options?: { readVaultObject?: VaultObjectReader }, |
| 140 | 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 | 170 | const provider = record.provider; |
| 142 | 171 | const resolver = providers[provider]; |
| 143 | 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 | 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 | 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 | 11 | export interface GqlTypeRef { |
| 5 | 12 | kind: string; |
| @@ -49,7 +56,7 @@ export function selectGraphqlFieldEnvelope( · 43 unmodified lines | ||
| 49 | 56 | envelope: GraphqlExecutionEnvelope, |
| 50 | 57 | operationName: string, |
| 51 | 58 | ): GraphqlExecutionEnvelope { |
| 52 | − const record = asRecord(envelope.data); | |
| 59 | + const record = coerceRecord(envelope.data); | |
| 53 | 60 | if (Object.prototype.hasOwnProperty.call(record, operationName)) { |
| 54 | 61 | return { |
| 55 | 62 | data: record[operationName], |
| @@ -82,7 +89,7 @@ export function normalizeGraphqlFieldVariables( · 26 unmodified lines | ||
| 82 | 89 | && typeof value === "object" |
| 83 | 90 | && !Array.isArray(value) |
| 84 | 91 | ) { |
| 85 | − const nested = asRecord(value); | |
| 92 | + const nested = coerceRecord(value); | |
| 86 | 93 | if (Object.keys(nested).length === 1 && Object.prototype.hasOwnProperty.call(nested, argName)) { |
| 87 | 94 | return { [argName]: nested[argName] }; |
| 88 | 95 | } |
| @@ -82,9 +82,10 @@ async function getConfiguredFunctionsPath(candidate: string): Promise<string | n · 81 unmodified lines | ||
| 82 | 82 | try { |
| 83 | 83 | const raw = await fs.readFile(path.join(candidate, "convex.json"), "utf8"); |
| 84 | 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 | 86 | return null; |
| 87 | 87 | } |
| 88 | + | |
| 88 | 89 | const value = parsed.data.functions.trim(); |
| 89 | 90 | return value.length > 0 ? value : null; |
| 90 | 91 | } catch { |
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | import fs from "node:fs/promises"; |
| 2 | 2 | import os from "node:os"; |
| 3 | 3 | import path from "node:path"; |
| 4 | −import { Result } from "better-result"; | |
| 5 | 4 | import { z } from "zod"; |
| 6 | 5 | |
| 7 | 6 | import type { ManagedRuntimeConfig, ManagedRuntimeInfo } from "../managed-runtime"; |
| @@ -151,10 +150,13 @@ export function runtimeInfo(): ManagedRuntimeInfo { · 143 unmodified lines | ||
| 151 | 150 | export async function ensureConfig(info: ManagedRuntimeInfo): Promise<ManagedRuntimeConfig> { |
| 152 | 151 | if (await pathExists(info.configPath)) { |
| 153 | 152 | const raw = await fs.readFile(info.configPath, "utf8"); |
| 154 | − const parsedResult = Result.try(() => JSON.parse(raw)); | |
| 155 | − const parsed = parsedResult.isOk() | |
| 156 | − ? parseManagedRuntimeConfig(parsedResult.value) | |
| 157 | − : null; | |
| 153 | + let parsedValue: unknown; | |
| 154 | + try { | |
| 155 | + parsedValue = JSON.parse(raw); | |
| 156 | + } catch { | |
| 157 | + parsedValue = null; | |
| 158 | + } | |
| 159 | + const parsed = parseManagedRuntimeConfig(parsedValue); | |
| 158 | 160 | |
| 159 | 161 | if (!parsed) { |
| 160 | 162 | const config = defaultConfig(); |
| @@ -9,7 +9,6 @@ import type { · 8 unmodified lines | ||
| 9 | 9 | McpExecutorService, |
| 10 | 10 | } from "./server-contracts"; |
| 11 | 11 | import { getTaskTerminalState } from "./server-utils"; |
| 12 | −import { asRecord } from "../utils"; | |
| 13 | 12 | |
| 14 | 13 | const elicitationResponseSchema = z.object({ |
| 15 | 14 | action: z.enum(["accept", "decline", "cancel"]), |
| @@ -19,6 +18,11 @@ const elicitationResponseSchema = z.object({ · 3 unmodified lines | ||
| 19 | 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 | 26 | function formatApprovalInput(input: unknown, maxLength = 2000): string { |
| 23 | 27 | let serialized: string; |
| 24 | 28 | try { |
| @@ -88,7 +92,7 @@ export function createMcpApprovalPrompt(mcp: McpServer): ApprovalPrompt { · 63 unmodified lines | ||
| 88 | 92 | } |
| 89 | 93 | |
| 90 | 94 | const action = parsedResponse.data.action; |
| 91 | − const content = asRecord(parsedResponse.data.content); | |
| 95 | + const content = parsedResponse.data.content; | |
| 92 | 96 | |
| 93 | 97 | if (action !== "accept") { |
| 94 | 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 | 107 | const decision = selectedDecision === "approved" ? "approved" : "denied"; |
| 104 | − const selectedReason = content.reason; | |
| 105 | − const reason = typeof selectedReason === "string" && selectedReason.trim().length > 0 | |
| 106 | − ? selectedReason | |
| 107 | − : undefined; | |
| 108 | + const selectedReason = content?.reason?.trim(); | |
| 109 | + const reason = selectedReason && selectedReason.length > 0 ? selectedReason : undefined; | |
| 108 | 110 | |
| 109 | 111 | return { decision, reason }; |
| 110 | 112 | }; |
| @@ -209,13 +211,13 @@ export function waitForTerminalTask( · 98 unmodified lines | ||
| 209 | 211 | }; |
| 210 | 212 | |
| 211 | 213 | unsubscribe = service.subscribe(taskId, workspaceId, (event) => { |
| 212 | − const payload = asRecord(event.payload); | |
| 213 | − const type = typeof payload.status === "string" ? payload.status : undefined; | |
| 214 | − const pendingApprovalCount = typeof payload.pendingApprovalCount === "number" | |
| 215 | − ? payload.pendingApprovalCount | |
| 214 | + const parsedPayload = subscriptionEventPayloadSchema.safeParse(event.payload); | |
| 215 | + const type = parsedPayload.success ? parsedPayload.data.status : undefined; | |
| 216 | + const pendingApprovalCount = parsedPayload.success | |
| 217 | + ? (parsedPayload.data.pendingApprovalCount ?? 0) | |
| 216 | 218 | : 0; |
| 217 | 219 | |
| 218 | − if (typeof type === "string" && getTaskTerminalState(type)) { | |
| 220 | + if (type && getTaskTerminalState(type)) { | |
| 219 | 221 | void done(); |
| 220 | 222 | return; |
| 221 | 223 | } |
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | import type { OpenApiAuth } from "./tool/source-types"; |
| 2 | 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 | 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 | 18 | export function inferOpenApiAuth(spec: Record<string, unknown>): OpenApiAuth | undefined { |
| 15 | − const components = asRecord(spec.components); | |
| 16 | − const securitySchemes = asRecord(components.securitySchemes); | |
| 19 | + const components = toRecordOrEmpty(spec.components); | |
| 20 | + const securitySchemes = toRecordOrEmpty(components.securitySchemes); | |
| 17 | 21 | if (Object.keys(securitySchemes).length === 0) { |
| 18 | 22 | return undefined; |
| 19 | 23 | } |
| @@ -11,7 +11,11 @@ import { · 10 unmodified lines | ||
| 11 | 11 | responseTypeHintFromSchema, |
| 12 | 12 | type OpenApiParameterHint, |
| 13 | 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 | 20 | export interface CompactOpenApiPathsOptions { |
| 17 | 21 | includeSchemas?: boolean; |
| @@ -28,13 +32,13 @@ export function compactOpenApiPaths( · 10 unmodified lines | ||
| 28 | 32 | componentRequestBodies?: Record<string, unknown>, |
| 29 | 33 | options: CompactOpenApiPathsOptions = {}, |
| 30 | 34 | ): Record<string, unknown> { |
| 31 | − const paths = asRecord(pathsValue); | |
| 35 | + const paths = toRecordOrEmpty(pathsValue); | |
| 32 | 36 | const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const; |
| 33 | 37 | const compactPaths: Record<string, unknown> = {}; |
| 34 | − const compParams = componentParameters ? asRecord(componentParameters) : {}; | |
| 35 | − const compSchemas = componentSchemas ? asRecord(componentSchemas) : {}; | |
| 36 | − const compResponses = componentResponses ? asRecord(componentResponses) : {}; | |
| 37 | − const compRequestBodies = componentRequestBodies ? asRecord(componentRequestBodies) : {}; | |
| 38 | + const compParams = componentParameters ? toRecordOrEmpty(componentParameters) : {}; | |
| 39 | + const compSchemas = componentSchemas ? toRecordOrEmpty(componentSchemas) : {}; | |
| 40 | + const compResponses = componentResponses ? toRecordOrEmpty(componentResponses) : {}; | |
| 41 | + const compRequestBodies = componentRequestBodies ? toRecordOrEmpty(componentRequestBodies) : {}; | |
| 38 | 42 | const includeSchemas = options.includeSchemas ?? true; |
| 39 | 43 | const includeTypeHints = options.includeTypeHints ?? true; |
| 40 | 44 | const includeParameterSchemas = options.includeParameterSchemas ?? true; |
| @@ -45,7 +49,7 @@ export function compactOpenApiPaths( · 4 unmodified lines | ||
| 45 | 49 | const prefix = "#/components/parameters/"; |
| 46 | 50 | if (ref.startsWith(prefix)) { |
| 47 | 51 | const key = ref.slice(prefix.length); |
| 48 | − const resolved = asRecord(compParams[key]); | |
| 52 | + const resolved = toRecordOrEmpty(compParams[key]); | |
| 49 | 53 | if (Object.keys(resolved).length > 0) return resolved; |
| 50 | 54 | } |
| 51 | 55 | } |
| @@ -55,7 +59,7 @@ export function compactOpenApiPaths( · 3 unmodified lines | ||
| 55 | 59 | const normalizeParameters = (entries: unknown): Array<OpenApiParameterHint & { in: string }> => { |
| 56 | 60 | if (!Array.isArray(entries)) return []; |
| 57 | 61 | return entries |
| 58 | − .map((entry) => resolveParam(asRecord(entry))) | |
| 62 | + .map((entry) => resolveParam(toRecordOrEmpty(entry))) | |
| 59 | 63 | .filter((entry) => typeof entry.name === "string" && typeof entry.in === "string") |
| 60 | 64 | .map((entry) => ({ |
| 61 | 65 | name: String(entry.name), |
| @@ -66,7 +70,7 @@ export function compactOpenApiPaths( · 4 unmodified lines | ||
| 66 | 70 | }; |
| 67 | 71 | |
| 68 | 72 | for (const [pathTemplate, pathValue] of Object.entries(paths)) { |
| 69 | − const pathObject = asRecord(pathValue); | |
| 73 | + const pathObject = toRecordOrEmpty(pathValue); | |
| 70 | 74 | const compactPathObject: Record<string, unknown> = {}; |
| 71 | 75 | const sharedParameters = normalizeParameters(pathObject.parameters); |
| 72 | 76 | if (sharedParameters.length > 0) { |
| @@ -74,7 +78,7 @@ export function compactOpenApiPaths( · 1 unmodified line | ||
| 74 | 78 | } |
| 75 | 79 | |
| 76 | 80 | for (const method of methods) { |
| 77 | − const operation = asRecord(pathObject[method]); | |
| 81 | + const operation = toRecordOrEmpty(pathObject[method]); | |
| 78 | 82 | if (Object.keys(operation).length === 0) continue; |
| 79 | 83 | |
| 80 | 84 | const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`); |
| @@ -106,16 +110,16 @@ export function compactOpenApiPaths( · 25 unmodified lines | ||
| 106 | 110 | // Always attempt to compute minimal input/output schemas. This keeps the |
| 107 | 111 | // prepared spec compact while enabling schema-first tool signatures. |
| 108 | 112 | if (includeSchemas || hasGeneratedTypes) { |
| 109 | − const requestBody = resolveRequestBodyRef(asRecord(operation.requestBody), compRequestBodies); | |
| 110 | − const requestBodyContent = asRecord(requestBody.content); | |
| 113 | + const requestBody = resolveRequestBodyRef(toRecordOrEmpty(operation.requestBody), compRequestBodies); | |
| 114 | + const requestBodyContent = toRecordOrEmpty(requestBody.content); | |
| 111 | 115 | const rawRequestBodySchema = getPreferredContentSchema(requestBodyContent); |
| 112 | 116 | requestBodySchema = resolveSchemaRef(rawRequestBodySchema, compSchemas); |
| 113 | 117 | |
| 114 | − const responses = asRecord(operation.responses); | |
| 118 | + const responses = toRecordOrEmpty(operation.responses); | |
| 115 | 119 | for (const [status, responseValue] of Object.entries(responses)) { |
| 116 | 120 | if (!status.startsWith("2")) continue; |
| 117 | 121 | responseStatus = status; |
| 118 | − const resolvedResponse = resolveResponseRef(asRecord(responseValue), compResponses); | |
| 122 | + const resolvedResponse = resolveResponseRef(toRecordOrEmpty(responseValue), compResponses); | |
| 119 | 123 | responseSchema = resolveSchemaRef( |
| 120 | 124 | getPreferredResponseSchema(resolvedResponse), |
| 121 | 125 | compSchemas, |
| @@ -6,10 +6,15 @@ import { inferOpenApiAuth } from "./openapi-auth"; · 5 unmodified lines | ||
| 6 | 6 | import { compactOpenApiPaths } from "./openapi-compaction"; |
| 7 | 7 | import { extractOperationIdsFromDts } from "./openapi/schema-hints"; |
| 8 | 8 | import type { PreparedOpenApiSpec } from "./tool/source-types"; |
| 9 | −import { asRecord } from "./utils"; | |
| 9 | +import { toPlainObject } from "./utils"; | |
| 10 | 10 | |
| 11 | 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 | 18 | interface SwaggerParserAdapter { |
| 14 | 19 | bundle(spec: unknown): Promise<unknown>; |
| 15 | 20 | parse(spec: unknown): Promise<unknown>; |
| @@ -41,16 +46,13 @@ function toRecordResult(value: unknown, label: string): Result<Record<string, un · 25 unmodified lines | ||
| 41 | 46 | return Result.ok(parsed.data); |
| 42 | 47 | } |
| 43 | 48 | |
| 44 | −function describeResultError(error: unknown): string { | |
| 45 | − const cause = asRecord(error).cause; | |
| 46 | − if (cause instanceof Error) { | |
| 47 | − return cause.message; | |
| 48 | − } | |
| 49 | − if (typeof cause === "string" && cause.trim().length > 0) { | |
| 50 | − return cause; | |
| 49 | +function toOpenApiTsInput(spec: Record<string, unknown>): Parameters<typeof openapiTS>[0] { | |
| 50 | + const parsed = openApiDocumentSchema.safeParse(spec); | |
| 51 | + if (!parsed.success) { | |
| 52 | + throw new Error(`OpenAPI document is invalid: ${parsed.error.message}`); | |
| 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 | 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 | 64 | let target: unknown = spec; |
| 63 | 65 | for (const segment of segments) { |
| 64 | 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 | 70 | } else { |
| 67 | 71 | return false; |
| 68 | 72 | } |
| @@ -73,11 +77,12 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 4 unmodified lines | ||
| 73 | 77 | function hasBrokenDiscriminators(obj: unknown): boolean { |
| 74 | 78 | if (Array.isArray(obj)) return obj.some(hasBrokenDiscriminators); |
| 75 | 79 | if (obj && typeof obj === "object") { |
| 76 | − const record = asRecord(obj); | |
| 80 | + const record = toPlainObject(obj); | |
| 81 | + if (!record) return false; | |
| 77 | 82 | if (record.discriminator && typeof record.discriminator === "object") { |
| 78 | − const disc = asRecord(record.discriminator); | |
| 83 | + const disc = toPlainObject(record.discriminator) ?? {}; | |
| 79 | 84 | if (disc.mapping && typeof disc.mapping === "object") { |
| 80 | − const mapping = asRecord(disc.mapping); | |
| 85 | + const mapping = toPlainObject(disc.mapping) ?? {}; | |
| 81 | 86 | if (Object.values(mapping).some((ref) => typeof ref === "string" && !refExists(ref))) { |
| 82 | 87 | return true; |
| 83 | 88 | } |
| @@ -93,13 +98,14 @@ function stripBrokenDiscriminators(spec: Record<string, unknown>): Record<string · 9 unmodified lines | ||
| 93 | 98 | function walk(obj: unknown): unknown { |
| 94 | 99 | if (Array.isArray(obj)) return obj.map(walk); |
| 95 | 100 | if (obj && typeof obj === "object") { |
| 96 | − const record = asRecord(obj); | |
| 101 | + const record = toPlainObject(obj); | |
| 102 | + if (!record) return obj; | |
| 97 | 103 | const clone: Record<string, unknown> = {}; |
| 98 | 104 | for (const [key, value] of Object.entries(record)) { |
| 99 | 105 | if (key === "discriminator" && typeof value === "object" && value !== null) { |
| 100 | − const disc = asRecord(value); | |
| 106 | + const disc = toPlainObject(value) ?? {}; | |
| 101 | 107 | if (disc.mapping && typeof disc.mapping === "object") { |
| 102 | − const mapping = asRecord(disc.mapping); | |
| 108 | + const mapping = toPlainObject(disc.mapping) ?? {}; | |
| 103 | 109 | const hasBroken = Object.values(mapping).some( |
| 104 | 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 | 134 | async function generateOpenApiDts(spec: Record<string, unknown>): Promise<string | null> { |
| 129 | 135 | try { |
| 130 | − const ast = await openapiTS(spec as never, { silent: true }); | |
| 136 | + const ast = await openapiTS(toOpenApiTsInput(spec), { silent: true }); | |
| 131 | 137 | return astToString(ast); |
| 132 | 138 | } catch (error) { |
| 133 | 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 | 141 | if (patched) { |
| 136 | 142 | console.warn(`[executor] openapi-typescript failed, retrying with patched spec: ${msg}`); |
| 137 | 143 | try { |
| 138 | − const ast = await openapiTS(patched as never, { silent: true }); | |
| 144 | + const ast = await openapiTS(toOpenApiTsInput(patched), { silent: true }); | |
| 139 | 145 | return astToString(ast); |
| 140 | 146 | } catch (retryError) { |
| 141 | 147 | const retryMsg = retryError instanceof Error ? retryError.message : String(retryError); |
| @@ -168,13 +174,15 @@ export async function prepareOpenApiSpec( · 26 unmodified lines | ||
| 168 | 174 | |
| 169 | 175 | let parsed: Record<string, unknown>; |
| 170 | 176 | if (typeof spec === "string") { |
| 171 | − const parsedResult = await Result.tryPromise(() => parser.parse(spec)); | |
| 172 | − if (parsedResult.isErr()) { | |
| 173 | − const msg = describeResultError(parsedResult.error); | |
| 177 | + let parsedValue: unknown; | |
| 178 | + try { | |
| 179 | + parsedValue = await parser.parse(spec); | |
| 180 | + } catch (error) { | |
| 181 | + const msg = error instanceof Error ? error.message : String(error); | |
| 174 | 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 | 186 | if (parsedRecord.isErr()) { |
| 179 | 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 | 192 | parsed = spec; |
| 185 | 193 | } |
| 186 | 194 | |
| 187 | − let bundled: Record<string, unknown>; | |
| 195 | + let bundled: Record<string, unknown> = parsed; | |
| 188 | 196 | const dtsPromise = shouldGenerateDts |
| 189 | 197 | ? generateOpenApiDts(parsed) |
| 190 | 198 | : Promise.resolve<string | null>(null); |
| 191 | 199 | if (shouldBundle) { |
| 192 | − const bundleResult = await Result.tryPromise(() => parser.bundle(parsed)); | |
| 193 | − if (bundleResult.isErr()) { | |
| 194 | − const bundleMessage = describeResultError(bundleResult.error); | |
| 200 | + let bundledValue: unknown; | |
| 201 | + try { | |
| 202 | + bundledValue = await parser.bundle(parsed); | |
| 203 | + } catch (error) { | |
| 204 | + const bundleMessage = error instanceof Error ? error.message : String(error); | |
| 195 | 205 | warnings.push(`OpenAPI bundle failed for '${sourceName}', using parse-only mode: ${bundleMessage}`); |
| 196 | 206 | bundled = parsed; |
| 197 | − } else { | |
| 198 | − const bundledRecord = toRecordResult(bundleResult.value, `Bundled OpenAPI source '${sourceName}'`); | |
| 207 | + bundledValue = undefined; | |
| 208 | + } | |
| 209 | + | |
| 210 | + if (bundledValue !== undefined) { | |
| 211 | + const bundledRecord = toRecordResult(bundledValue, `Bundled OpenAPI source '${sourceName}'`); | |
| 199 | 212 | if (bundledRecord.isErr()) { |
| 200 | 213 | warnings.push(`OpenAPI bundle returned non-object payload for '${sourceName}', using parse-only mode`); |
| 201 | 214 | bundled = parsed; |
| @@ -203,8 +216,6 @@ export async function prepareOpenApiSpec( · 1 unmodified line | ||
| 203 | 216 | bundled = bundledRecord.value; |
| 204 | 217 | } |
| 205 | 218 | } |
| 206 | − } else { | |
| 207 | − bundled = parsed; | |
| 208 | 219 | } |
| 209 | 220 | const dts = await dtsPromise; |
| 210 | 221 | |
| @@ -215,17 +226,17 @@ export async function prepareOpenApiSpec( · 4 unmodified lines | ||
| 215 | 226 | return { |
| 216 | 227 | servers: servers |
| 217 | 228 | .map((server) => { |
| 218 | − const value = asRecord(server).url; | |
| 229 | + const value = toPlainObject(server)?.url; | |
| 219 | 230 | return typeof value === "string" ? value : ""; |
| 220 | 231 | }) |
| 221 | 232 | .filter((url) => url.length > 0), |
| 222 | 233 | paths: compactOpenApiPaths( |
| 223 | 234 | bundled.paths, |
| 224 | 235 | operationTypeIds, |
| 225 | − asRecord(asRecord(bundled.components).parameters), | |
| 226 | − asRecord(asRecord(bundled.components).schemas), | |
| 227 | − asRecord(asRecord(bundled.components).responses), | |
| 228 | − asRecord(asRecord(bundled.components).requestBodies), | |
| 236 | + toPlainObject(toPlainObject(bundled.components)?.parameters) ?? {}, | |
| 237 | + toPlainObject(toPlainObject(bundled.components)?.schemas) ?? {}, | |
| 238 | + toPlainObject(toPlainObject(bundled.components)?.responses) ?? {}, | |
| 239 | + toPlainObject(toPlainObject(bundled.components)?.requestBodies) ?? {}, | |
| 229 | 240 | { |
| 230 | 241 | includeSchemas: profile === "full", |
| 231 | 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 | 7 | type JsonSchema = Record<string, unknown>; |
| 4 | 8 | const COMPONENT_REF_INLINE_DEPTH = 2; |
| @@ -6,7 +10,7 @@ const COMPONENT_REF_INLINE_DEPTH = 2; · 1 unmodified line | ||
| 6 | 10 | function isSmallInlineableComponentSchema(schema: Record<string, unknown>): boolean { |
| 7 | 11 | const shape = schema; |
| 8 | 12 | const type = typeof shape.type === "string" ? shape.type : undefined; |
| 9 | − const props = asRecord(shape.properties); | |
| 13 | + const props = toRecordOrEmpty(shape.properties); | |
| 10 | 14 | const propCount = Object.keys(props).length; |
| 11 | 15 | if (type !== "object" && propCount === 0) return false; |
| 12 | 16 | if (propCount === 0) return false; |
| @@ -38,18 +42,18 @@ export function getPreferredContentSchema(content: Record<string, unknown>): Rec · 25 unmodified lines | ||
| 38 | 42 | const preferredKeys = ["application/json", "*/*"]; |
| 39 | 43 | |
| 40 | 44 | for (const key of preferredKeys) { |
| 41 | − const schema = asRecord(asRecord(content[key]).schema); | |
| 45 | + const schema = toRecordOrEmpty(toRecordOrEmpty(content[key]).schema); | |
| 42 | 46 | if (Object.keys(schema).length > 0) return schema; |
| 43 | 47 | } |
| 44 | 48 | |
| 45 | 49 | for (const [key, value] of Object.entries(content)) { |
| 46 | 50 | if (!key.includes("json")) continue; |
| 47 | − const schema = asRecord(asRecord(value).schema); | |
| 51 | + const schema = toRecordOrEmpty(toRecordOrEmpty(value).schema); | |
| 48 | 52 | if (Object.keys(schema).length > 0) return schema; |
| 49 | 53 | } |
| 50 | 54 | |
| 51 | 55 | for (const value of Object.values(content)) { |
| 52 | − const schema = asRecord(asRecord(value).schema); | |
| 56 | + const schema = toRecordOrEmpty(toRecordOrEmpty(value).schema); | |
| 53 | 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 | 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 | 65 | if (Object.keys(contentSchema).length > 0) { |
| 62 | 66 | return contentSchema; |
| 63 | 67 | } |
| 64 | 68 | |
| 65 | − const schema = asRecord(responseValue.schema); | |
| 69 | + const schema = toRecordOrEmpty(responseValue.schema); | |
| 66 | 70 | if (Object.keys(schema).length > 0) { |
| 67 | 71 | return schema; |
| 68 | 72 | } |
| @@ -81,7 +85,7 @@ export function resolveSchemaRef( · 12 unmodified lines | ||
| 81 | 85 | } |
| 82 | 86 | |
| 83 | 87 | const key = ref.slice(prefix.length); |
| 84 | − const resolved = asRecord(componentSchemas[key]); | |
| 88 | + const resolved = toRecordOrEmpty(componentSchemas[key]); | |
| 85 | 89 | if (Object.keys(resolved).length === 0) { |
| 86 | 90 | return schema; |
| 87 | 91 | } |
| @@ -99,7 +103,7 @@ export function resolveRequestBodyRef( · 11 unmodified lines | ||
| 99 | 103 | } |
| 100 | 104 | |
| 101 | 105 | const key = ref.slice(prefix.length); |
| 102 | − const resolved = asRecord(componentRequestBodies[key]); | |
| 106 | + const resolved = toRecordOrEmpty(componentRequestBodies[key]); | |
| 103 | 107 | if (Object.keys(resolved).length === 0) { |
| 104 | 108 | return requestBody; |
| 105 | 109 | } |
| @@ -117,7 +121,7 @@ export function resolveResponseRef( · 11 unmodified lines | ||
| 117 | 121 | } |
| 118 | 122 | |
| 119 | 123 | const key = ref.slice(prefix.length); |
| 120 | − const resolved = asRecord(componentResponses[key]); | |
| 124 | + const resolved = toRecordOrEmpty(componentResponses[key]); | |
| 121 | 125 | if (Object.keys(resolved).length === 0) { |
| 122 | 126 | return response; |
| 123 | 127 | } |
| @@ -125,7 +129,7 @@ export function resolveResponseRef( · 1 unmodified line | ||
| 125 | 129 | } |
| 126 | 130 | |
| 127 | 131 | export function parameterSchemaFromEntry(entry: Record<string, unknown>): Record<string, unknown> { |
| 128 | − const schema = asRecord(entry.schema); | |
| 132 | + const schema = toRecordOrEmpty(entry.schema); | |
| 129 | 133 | if (Object.keys(schema).length > 0) { |
| 130 | 134 | return schema; |
| 131 | 135 | } |
| @@ -139,7 +143,7 @@ export function parameterSchemaFromEntry(entry: Record<string, unknown>): Record · 7 unmodified lines | ||
| 139 | 143 | if (Array.isArray(entry.enum) && entry.enum.length > 0) { |
| 140 | 144 | fallback.enum = entry.enum; |
| 141 | 145 | } |
| 142 | − const items = asRecord(entry.items); | |
| 146 | + const items = toRecordOrEmpty(entry.items); | |
| 143 | 147 | if (Object.keys(items).length > 0) { |
| 144 | 148 | fallback.items = items; |
| 145 | 149 | } |
| @@ -239,13 +243,13 @@ function joinUnion(parts: string[]): string { · 93 unmodified lines | ||
| 239 | 243 | function isObjectSchema(schema: JsonSchema): boolean { |
| 240 | 244 | const type = typeof schema.type === "string" ? schema.type : undefined; |
| 241 | 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 | 249 | function isEmptyObjectSchema(schema: JsonSchema): boolean { |
| 246 | 250 | const type = typeof schema.type === "string" ? schema.type : undefined; |
| 247 | 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 | 255 | function isPlainScalarSchema(schema: JsonSchema): boolean { |
| @@ -279,7 +283,7 @@ function normalizeUnionSchemaVariants(variants: JsonSchema[]): JsonSchema[] { · 27 unmodified lines | ||
| 279 | 283 | // Many real-world OpenAPI specs include `oneOf: [{type:"object"}, { ...specific... }]` |
| 280 | 284 | // or even `oneOf: [{type:"string"}, { ...object... }, { ...object... }]`. |
| 281 | 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 | 288 | let filtered = variants; |
| 285 | 289 | if (hasNonEmptyObject) { |
| @@ -324,7 +328,7 @@ function repairMissingRequiredProperties( · 38 unmodified lines | ||
| 324 | 328 | const requiredLists = variants.map((v) => new Set( |
| 325 | 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 | 333 | const keysToConsider = new Set<string>(); |
| 330 | 334 | for (const req of requiredLists) { |
| @@ -342,7 +346,7 @@ function repairMissingRequiredProperties( · 11 unmodified lines | ||
| 342 | 346 | if (!schema || typeof schema !== "object") continue; |
| 343 | 347 | const hint = jsonSchemaTypeHintFallback(schema, depth + 1, componentSchemas, seenRefs); |
| 344 | 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 | 352 | if (candidates.length === 0) continue; |
| @@ -416,8 +420,8 @@ function mergeDiscriminatedObjectUnionVariants( · 67 unmodified lines | ||
| 416 | 420 | |
| 417 | 421 | for (let i = 0; i < variants.length; i++) { |
| 418 | 422 | const variant = variants[i]!; |
| 419 | − const props = asRecord(variant.properties); | |
| 420 | − const discSchema = asRecord(props[discriminantKey]); | |
| 423 | + const props = toRecordOrEmpty(variant.properties); | |
| 424 | + const discSchema = toRecordOrEmpty(props[discriminantKey]); | |
| 421 | 425 | const disc = extractStringLiteralFromSchema(discSchema); |
| 422 | 426 | if (!disc) { |
| 423 | 427 | return variants; |
| @@ -479,8 +483,8 @@ function mergeDiscriminatedObjectUnionVariants( · 55 unmodified lines | ||
| 479 | 483 | for (const member of group) used.add(member.idx); |
| 480 | 484 | |
| 481 | 485 | const base = group[0]!.schema; |
| 482 | − const baseProps = asRecord(base.properties); | |
| 483 | − const discSchema = asRecord(baseProps[discriminantKey]); | |
| 486 | + const baseProps = toRecordOrEmpty(base.properties); | |
| 487 | + const discSchema = toRecordOrEmpty(baseProps[discriminantKey]); | |
| 484 | 488 | const discType = typeof discSchema.type === "string" ? discSchema.type : "string"; |
| 485 | 489 | |
| 486 | 490 | const mergedDiscriminant: JsonSchema = { |
| @@ -521,8 +525,8 @@ function tryCollapseSimpleObjectUnion( · 34 unmodified lines | ||
| 521 | 525 | const right = variants[1]!; |
| 522 | 526 | if (!isObjectSchema(left) || !isObjectSchema(right)) return null; |
| 523 | 527 | |
| 524 | − const leftProps = asRecord(left.properties); | |
| 525 | − const rightProps = asRecord(right.properties); | |
| 528 | + const leftProps = toRecordOrEmpty(left.properties); | |
| 529 | + const rightProps = toRecordOrEmpty(right.properties); | |
| 526 | 530 | const leftKeys = Object.keys(leftProps); |
| 527 | 531 | const rightKeys = Object.keys(rightProps); |
| 528 | 532 | if (leftKeys.length === 0 || rightKeys.length === 0) return null; |
| @@ -535,8 +539,8 @@ function tryCollapseSimpleObjectUnion( · 6 unmodified lines | ||
| 535 | 539 | |
| 536 | 540 | const sup = leftSubRight ? right : left; |
| 537 | 541 | const sub = leftSubRight ? left : right; |
| 538 | − const supProps = asRecord(sup.properties); | |
| 539 | − const subProps = asRecord(sub.properties); | |
| 542 | + const supProps = toRecordOrEmpty(sup.properties); | |
| 543 | + const subProps = toRecordOrEmpty(sub.properties); | |
| 540 | 544 | const supKeys = Object.keys(supProps); |
| 541 | 545 | const subKeys = Object.keys(subProps); |
| 542 | 546 | const extraKeys = supKeys.filter((k) => !subKeys.includes(k)); |
| @@ -591,13 +595,13 @@ function tryFactorCommonObjectFields( · 48 unmodified lines | ||
| 591 | 595 | if (variants.length < 2) return null; |
| 592 | 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 | 599 | const commonKeys = intersectKeys(propsList); |
| 596 | 600 | if (commonKeys.length === 0) return null; |
| 597 | 601 | |
| 598 | 602 | const commonEntries: Array<{ key: string; hint: string; required: boolean }> = []; |
| 599 | 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 | 605 | const firstHint = hints[0]; |
| 602 | 606 | if (!firstHint || hints.some((h) => h !== firstHint)) continue; |
| 603 | 607 | |
| @@ -613,7 +617,7 @@ function tryFactorCommonObjectFields( · 9 unmodified lines | ||
| 613 | 617 | const commonProps = new Set(commonEntries.map((e) => e.key)); |
| 614 | 618 | const residualSchemas: JsonSchema[] = []; |
| 615 | 619 | for (const variant of variants) { |
| 616 | − const props = asRecord(variant.properties); | |
| 620 | + const props = toRecordOrEmpty(variant.properties); | |
| 617 | 621 | const residualProps = Object.fromEntries(Object.entries(props).filter(([key]) => !commonProps.has(key))); |
| 618 | 622 | const req = Array.isArray(variant.required) ? variant.required : []; |
| 619 | 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 | 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 | 633 | if (!reduces) return null; |
| 630 | 634 | |
| 631 | 635 | const commonInner = commonEntries |
| @@ -662,7 +666,7 @@ function tryFactorPartialCommonObjectFields( · 30 unmodified lines | ||
| 662 | 666 | if (variants.length < 3) return null; |
| 663 | 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 | 670 | const requiredList = variants.map((v) => new Set( |
| 667 | 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 | 758 | // If factoring doesn't actually remove anything useful, bail. |
| 755 | 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 | 762 | if (!anyResidualHasProps) return null; |
| 759 | 763 | |
| @@ -788,13 +792,13 @@ export function jsonSchemaTypeHintFallback( · 28 unmodified lines | ||
| 788 | 792 | if (!schema || typeof schema !== "object") return "unknown"; |
| 789 | 793 | if (depth > 12) return "unknown"; |
| 790 | 794 | |
| 791 | − const shape = asRecord(schema); | |
| 795 | + const shape = toRecordOrEmpty(schema); | |
| 792 | 796 | if (typeof shape.$ref === "string") { |
| 793 | 797 | const ref = shape.$ref; |
| 794 | 798 | const prefix = "#/components/schemas/"; |
| 795 | 799 | if (ref.startsWith(prefix)) { |
| 796 | 800 | const key = ref.slice(prefix.length); |
| 797 | − const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {}; | |
| 801 | + const resolved = componentSchemas ? toRecordOrEmpty(componentSchemas[key]) : {}; | |
| 798 | 802 | const canInline = Object.keys(resolved).length > 0 |
| 799 | 803 | && !seenRefs.has(ref) |
| 800 | 804 | && (depth < COMPONENT_REF_INLINE_DEPTH || isSmallInlineableComponentSchema(resolved)); |
| @@ -934,7 +938,7 @@ export function jsonSchemaTypeHintFallback( · 133 unmodified lines | ||
| 934 | 938 | return `${maybeParenthesizeArrayElement(itemType)}[]`; |
| 935 | 939 | } |
| 936 | 940 | |
| 937 | − const props = asRecord(shape.properties); | |
| 941 | + const props = toRecordOrEmpty(shape.properties); | |
| 938 | 942 | const additionalProperties = shape.additionalProperties; |
| 939 | 943 | const requiredRaw = Array.isArray(shape.required) ? shape.required : []; |
| 940 | 944 | const required = new Set(requiredRaw.filter((item): item is string => typeof item === "string")); |
| @@ -973,12 +977,12 @@ function collectTopLevelSchemaKeys( · 32 unmodified lines | ||
| 973 | 977 | ): string[] { |
| 974 | 978 | if (!schema || typeof schema !== "object") return []; |
| 975 | 979 | |
| 976 | − const record = asRecord(schema); | |
| 980 | + const record = toRecordOrEmpty(schema); | |
| 977 | 981 | const ref = typeof record.$ref === "string" ? record.$ref : ""; |
| 978 | 982 | if (ref.startsWith("#/components/schemas/")) { |
| 979 | 983 | if (seenRefs.has(ref)) return []; |
| 980 | 984 | const key = ref.slice("#/components/schemas/".length); |
| 981 | − const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {}; | |
| 985 | + const resolved = componentSchemas ? toRecordOrEmpty(componentSchemas[key]) : {}; | |
| 982 | 986 | if (Object.keys(resolved).length === 0) return []; |
| 983 | 987 | const nextSeen = new Set(seenRefs); |
| 984 | 988 | nextSeen.add(ref); |
| @@ -988,7 +992,7 @@ function collectTopLevelSchemaKeys( · 3 unmodified lines | ||
| 988 | 992 | const keys: string[] = []; |
| 989 | 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 | 996 | pushUnique(keys, seen, key); |
| 993 | 997 | } |
| 994 | 998 | |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { z } from "zod"; | |
| 1 | 2 | import { |
| 2 | 3 | buildOpenApiArgPreviewKeys, |
| 3 | 4 | buildOpenApiInputSchema, |
| @@ -6,7 +7,6 @@ import { · 2 unmodified lines | ||
| 6 | 7 | jsonSchemaTypeHintFallback, |
| 7 | 8 | responseTypeHintFromSchema, |
| 8 | 9 | } from "./schema-hints"; |
| 9 | −import { z } from "zod"; | |
| 10 | 10 | import { buildOpenApiToolPath } from "./tool-path"; |
| 11 | 11 | import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth"; |
| 12 | 12 | import { executeOpenApiRequest } from "../tool/source-execution"; |
| @@ -14,7 +14,7 @@ import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "../tool/sourc · 1 unmodified line | ||
| 14 | 14 | import type { ToolDefinition } from "../types"; |
| 15 | 15 | import type { ToolTypedRef } from "../types"; |
| 16 | 16 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils"; |
| 17 | −import { asRecord } from "../utils"; | |
| 17 | +import { toPlainObject } from "../utils"; | |
| 18 | 18 | import type { SerializedTool } from "../tool/source-serialization"; |
| 19 | 19 | |
| 20 | 20 | type OpenApiOperationParameter = { |
| @@ -24,11 +24,29 @@ type OpenApiOperationParameter = { · 3 unmodified lines | ||
| 24 | 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 | − const parsed = recordArraySchema.safeParse(value); | |
| 31 | − return parsed.success ? parsed.data : []; | |
| 30 | +function toRecordOrEmpty(value: unknown): Record<string, unknown> { | |
| 31 | + return toPlainObject(value) ?? {}; | |
| 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 | 52 | function buildOpenApiOperationParameters( |
| @@ -37,12 +55,12 @@ function buildOpenApiOperationParameters( · 2 unmodified lines | ||
| 37 | 55 | ): OpenApiOperationParameter[] { |
| 38 | 56 | return [ |
| 39 | 57 | ...sharedParameters, |
| 40 | − ...asRecordArray(operation.parameters), | |
| 58 | + ...toRecordArray(operation.parameters), | |
| 41 | 59 | ].map((entry) => ({ |
| 42 | 60 | name: String(entry.name ?? ""), |
| 43 | 61 | in: String(entry.in ?? "query"), |
| 44 | 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 | 78 | const sourceLabel = `openapi:${config.name}`; |
| 61 | 79 | const credentialSourceKey = getCredentialSourceKey(config); |
| 62 | 80 | const credentialSpec = buildCredentialSpec(credentialSourceKey, effectiveAuth); |
| 63 | − const paths = asRecord(prepared.paths); | |
| 81 | + const paths = toRecordOrEmpty(prepared.paths); | |
| 64 | 82 | const tools: ToolDefinition[] = []; |
| 65 | 83 | |
| 66 | 84 | const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const; |
| @@ -68,11 +86,11 @@ export function buildOpenApiToolsFromPrepared( · 1 unmodified line | ||
| 68 | 86 | const usedToolPaths = new Set<string>(); |
| 69 | 87 | |
| 70 | 88 | for (const [pathTemplate, pathValue] of Object.entries(paths)) { |
| 71 | − const pathObject = asRecord(pathValue); | |
| 72 | − const sharedParameters = asRecordArray(pathObject.parameters); | |
| 89 | + const pathObject = toRecordOrEmpty(pathValue); | |
| 90 | + const sharedParameters = toRecordArray(pathObject.parameters); | |
| 73 | 91 | |
| 74 | 92 | for (const method of methods) { |
| 75 | − const operation = asRecord(pathObject[method]); | |
| 93 | + const operation = toRecordOrEmpty(pathObject[method]); | |
| 76 | 94 | if (Object.keys(operation).length === 0) continue; |
| 77 | 95 | |
| 78 | 96 | const tags = Array.isArray(operation.tags) ? operation.tags : []; |
| @@ -80,22 +98,19 @@ export function buildOpenApiToolsFromPrepared( · 1 unmodified line | ||
| 80 | 98 | const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`); |
| 81 | 99 | const parameters = buildOpenApiOperationParameters(sharedParameters, operation); |
| 82 | 100 | |
| 83 | − const inputSchema = asRecord(operation._inputSchema); | |
| 84 | − const outputSchema = asRecord(operation._outputSchema); | |
| 85 | − const inputHint = typeof operation._argsTypeHint === "string" && operation._argsTypeHint.trim().length > 0 | |
| 86 | − ? operation._argsTypeHint.trim() | |
| 87 | − : undefined; | |
| 88 | − const outputHint = typeof operation._returnsTypeHint === "string" && operation._returnsTypeHint.trim().length > 0 | |
| 89 | − ? operation._returnsTypeHint.trim() | |
| 90 | − : undefined; | |
| 101 | + const inputSchema = toRecordOrEmpty(operation._inputSchema); | |
| 102 | + const outputSchema = toRecordOrEmpty(operation._outputSchema); | |
| 103 | + const inputHint = parseNonEmptyTrimmedString(operation._argsTypeHint); | |
| 104 | + const outputHint = parseNonEmptyTrimmedString(operation._returnsTypeHint); | |
| 91 | 105 | const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema); |
| 92 | − const previewInputKeys = Array.isArray(operation._previewInputKeys) | |
| 93 | − ? operation._previewInputKeys.filter((value): value is string => typeof value === "string") | |
| 106 | + const parsedPreviewInputKeys = stringArraySchema.safeParse(operation._previewInputKeys); | |
| 107 | + const previewInputKeys = parsedPreviewInputKeys.success | |
| 108 | + ? parsedPreviewInputKeys.data | |
| 94 | 109 | : buildPreviewKeys(inputSchema); |
| 95 | 110 | |
| 96 | 111 | const typedRef: ToolTypedRef = { |
| 97 | 112 | kind: "openapi_operation", |
| 98 | − sourceKey: sourceLabel, | |
| 113 | + sourceKey: config.sourceKey ?? sourceLabel, | |
| 99 | 114 | operationId: operationIdRaw, |
| 100 | 115 | }; |
| 101 | 116 | |
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | import { asStringRecord, detectJsonContentType, findUnresolvedPostmanTemplateKeys, interpolatePostmanTemplate, stringifyTemplateValue } from "./postman-utils"; |
| 2 | 2 | import { z } from "zod"; |
| 3 | −import { asRecord } from "./utils"; | |
| 4 | 3 | import type { PostmanRequestBody } from "./postman/collection-utils"; |
| 5 | 4 | |
| 6 | 5 | export interface PostmanSerializedRunSpec { |
| @@ -21,6 +20,13 @@ const postmanRuntimePayloadSchema = z.object({ · 14 unmodified lines | ||
| 21 | 20 | body: z.unknown().optional(), |
| 22 | 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 | 30 | export async function executePostmanRequest( |
| 25 | 31 | runSpec: PostmanSerializedRunSpec, |
| 26 | 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 | 65 | for (const [key, value] of Object.entries(queryOverrides)) { |
| 60 | 66 | if (!key || value === undefined || value === null) continue; |
| 61 | 67 | url.searchParams.set(key, stringifyTemplateValue(value)); |
| @@ -69,7 +75,7 @@ export async function executePostmanRequest( · 7 unmodified lines | ||
| 69 | 75 | Object.assign(headers, runSpec.authHeaders); |
| 70 | 76 | Object.assign(headers, credentialHeaders ?? {}); |
| 71 | 77 | |
| 72 | − const headerOverrides = asRecord(normalizedPayload.headers); | |
| 78 | + const headerOverrides = coerceRecord(normalizedPayload.headers); | |
| 73 | 79 | for (const [name, value] of Object.entries(headerOverrides)) { |
| 74 | 80 | if (!name || value === undefined || value === null) continue; |
| 75 | 81 | headers[name] = stringifyTemplateValue(value); |
| @@ -1,6 +1,12 @@ | ||
| 1 | −import { asRecord } from "./utils"; | |
| 1 | +import { z } from "zod"; | |
| 2 | 2 | |
| 3 | 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 | 11 | export function stringifyTemplateValue(value: unknown): string { |
| 6 | 12 | if (value === null || value === undefined) return ""; |
| @@ -29,7 +35,7 @@ export function findUnresolvedPostmanTemplateKeys(value: string): string[] { · 22 unmodified lines | ||
| 29 | 35 | |
| 30 | 36 | export function asStringRecord(value: unknown): Record<string, string> { |
| 31 | 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 | 39 | result[key] = stringifyTemplateValue(entry); |
| 34 | 40 | } |
| 35 | 41 | return result; |
| @@ -1,11 +1,50 @@ | ||
| 1 | 1 | import { sanitizeSegment, sanitizeSnakeSegment } from "../tool/path-utils"; |
| 2 | 2 | import { stringifyTemplateValue } from "../postman-utils"; |
| 3 | −import { asRecord } from "../utils"; | |
| 3 | +import { z } from "zod"; | |
| 4 | 4 | |
| 5 | 5 | export type PostmanRequestBody = |
| 6 | 6 | | { kind: "urlencoded"; entries: Array<{ key: string; value: string }> } |
| 7 | 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 | 48 | export function buildPostmanToolPath( |
| 10 | 49 | sourceName: string, |
| 11 | 50 | requestName: string, |
| @@ -48,57 +87,63 @@ export function resolvePostmanFolderPath( · 36 unmodified lines | ||
| 48 | 87 | } |
| 49 | 88 | |
| 50 | 89 | export function extractPostmanVariableMap(value: unknown): Record<string, string> { |
| 51 | − if (!Array.isArray(value)) return {}; | |
| 52 | 90 | const result: Record<string, string> = {}; |
| 53 | − for (const entry of value) { | |
| 54 | − const record = asRecord(entry); | |
| 55 | − const key = typeof record.key === "string" ? record.key.trim() : ""; | |
| 56 | − if (!key) continue; | |
| 57 | − if (record.disabled === true) continue; | |
| 58 | − result[key] = stringifyTemplateValue(record.value); | |
| 91 | + for (const entry of parseKeyValueEntries(value)) { | |
| 92 | + const normalized = toNormalizedEntry(entry); | |
| 93 | + if (!normalized) continue; | |
| 94 | + result[normalized.key] = normalized.value; | |
| 59 | 95 | } |
| 60 | 96 | return result; |
| 61 | 97 | } |
| 62 | 98 | |
| 63 | 99 | export function extractPostmanHeaderMap(value: unknown): Record<string, string> { |
| 64 | − if (!Array.isArray(value)) return {}; | |
| 65 | 100 | const result: Record<string, string> = {}; |
| 66 | − for (const entry of value) { | |
| 67 | − const record = asRecord(entry); | |
| 68 | − const key = typeof record.key === "string" ? record.key.trim() : ""; | |
| 69 | − if (!key || record.disabled === true) continue; | |
| 70 | − result[key] = stringifyTemplateValue(record.value); | |
| 101 | + for (const entry of parseKeyValueEntries(value)) { | |
| 102 | + const normalized = toNormalizedEntry(entry); | |
| 103 | + if (!normalized) continue; | |
| 104 | + result[normalized.key] = normalized.value; | |
| 71 | 105 | } |
| 72 | 106 | return result; |
| 73 | 107 | } |
| 74 | 108 | |
| 75 | 109 | export function extractPostmanQueryEntries(value: unknown): Array<{ key: string; value: string }> { |
| 76 | − if (!Array.isArray(value)) return []; | |
| 77 | 110 | const entries: Array<{ key: string; value: string }> = []; |
| 78 | − for (const entry of value) { | |
| 79 | − const record = asRecord(entry); | |
| 80 | − const key = typeof record.key === "string" ? record.key.trim() : ""; | |
| 81 | − if (!key || record.disabled === true) continue; | |
| 82 | − entries.push({ key, value: stringifyTemplateValue(record.value) }); | |
| 111 | + for (const entry of parseKeyValueEntries(value)) { | |
| 112 | + const normalized = toNormalizedEntry(entry); | |
| 113 | + if (!normalized) continue; | |
| 114 | + entries.push(normalized); | |
| 83 | 115 | } |
| 84 | 116 | return entries; |
| 85 | 117 | } |
| 86 | 118 | |
| 87 | −export function extractPostmanBody(record: Record<string, unknown>): PostmanRequestBody | undefined { | |
| 88 | − const dataMode = typeof record.dataMode === "string" ? record.dataMode.toLowerCase() : ""; | |
| 89 | − if (dataMode === "urlencoded" && Array.isArray(record.data)) { | |
| 90 | − const entries: Array<{ key: string; value: string }> = []; | |
| 91 | − for (const item of record.data) { | |
| 92 | − const entry = asRecord(item); | |
| 93 | − const key = typeof entry.key === "string" ? entry.key.trim() : ""; | |
| 94 | − if (!key || entry.disabled === true) continue; | |
| 95 | − entries.push({ key, value: stringifyTemplateValue(entry.value) }); | |
| 96 | − } | |
| 119 | +export function extractPostmanBody(value: unknown): PostmanRequestBody | undefined { | |
| 120 | + const parsedBody = postmanBodySchema.safeParse(value); | |
| 121 | + if (!parsedBody.success) return undefined; | |
| 122 | + | |
| 123 | + const body = parsedBody.data; | |
| 124 | + const mode = (body.mode ?? body.dataMode ?? "").trim().toLowerCase(); | |
| 125 | + | |
| 126 | + if (mode === "urlencoded") { | |
| 127 | + const rawEntries = body.urlencoded ?? body.data ?? []; | |
| 128 | + const entries = rawEntries | |
| 129 | + .map((entry) => toNormalizedEntry(entry)) | |
| 130 | + .filter((entry): entry is { key: string; value: string } => Boolean(entry)); | |
| 97 | 131 | return entries.length > 0 ? { kind: "urlencoded", entries } : undefined; |
| 98 | 132 | } |
| 99 | 133 | |
| 100 | − if (typeof record.rawModeData === "string" && record.rawModeData.length > 0) { | |
| 101 | − return { kind: "raw", text: record.rawModeData }; | |
| 134 | + if (mode === "raw") { | |
| 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 | 149 | return undefined; |
| @@ -39,16 +39,22 @@ function sanitizeExecutionResult(value: unknown): unknown { · 38 unmodified lines | ||
| 39 | 39 | return undefined; |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | − const serialized = Result.try(() => JSON.stringify(value)); | |
| 43 | − if (serialized.isErr()) { | |
| 42 | + let serialized: string | undefined; | |
| 43 | + try { | |
| 44 | + serialized = JSON.stringify(value); | |
| 45 | + } catch { | |
| 44 | 46 | return String(value); |
| 45 | 47 | } |
| 46 | 48 | |
| 47 | − if (serialized.value === undefined) { | |
| 49 | + if (serialized === undefined) { | |
| 48 | 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 | 60 | function createToolsProxy( |
| @@ -10,7 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | } from "./tool-discovery/formatting"; |
| 11 | 11 | import { buildIndex, getTopLevelNamespace, listIndexForContext } from "./tool-discovery/indexing"; |
| 12 | 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 | 15 | const catalogToolsInputSchema = z.object({ |
| 16 | 16 | namespace: z.string().optional(), |
| @@ -87,8 +87,9 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 70 unmodified lines | ||
| 87 | 87 | outputSchema: { type: "object", properties: { results: { type: "array" }, total: { type: "number" } }, required: ["results", "total"] }, |
| 88 | 88 | }, |
| 89 | 89 | run: async (input: unknown, context) => { |
| 90 | − const payload = asRecord(input); | |
| 91 | − const parsedInput = catalogToolsInputSchema.safeParse(payload); | |
| 90 | + const payload = recordSchema.safeParse(input); | |
| 91 | + const normalizedPayload = payload.success ? payload.data : {}; | |
| 92 | + const parsedInput = catalogToolsInputSchema.safeParse(normalizedPayload); | |
| 92 | 93 | const namespaceFilter = (parsedInput.success ? (parsedInput.data.namespace ?? "") : "").trim().toLowerCase(); |
| 93 | 94 | const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase(); |
| 94 | 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 | 180 | run: async (input: unknown, context) => { |
| 180 | − const payload = asRecord(input); | |
| 181 | − const parsedInput = discoverInputSchema.safeParse(payload); | |
| 181 | + const payload = recordSchema.safeParse(input); | |
| 182 | + const normalizedPayload = payload.success ? payload.data : {}; | |
| 183 | + const parsedInput = discoverInputSchema.safeParse(normalizedPayload); | |
| 182 | 184 | const query = (parsedInput.success ? (parsedInput.data.query ?? "") : "").trim().toLowerCase(); |
| 183 | 185 | const depthValue = parsedInput.success ? parsedInput.data.depth : undefined; |
| 184 | 186 | const depth = Math.max(0, Math.min(2, Number(depthValue ?? 1))); |
| @@ -1,8 +1,8 @@ | ||
| 1 | +import { z } from "zod"; | |
| 1 | 2 | import type { ToolDefinition } from "../types"; |
| 2 | 3 | import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints"; |
| 3 | 4 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils"; |
| 4 | 5 | import { sanitizeJsonSchemaForConvex } from "../tool-typing/convex-sanitize"; |
| 5 | −import { asRecord } from "../utils"; | |
| 6 | 6 | import type { DiscoverIndexEntry } from "./types"; |
| 7 | 7 | |
| 8 | 8 | const GENERIC_NAMESPACE_SUFFIXES = new Set([ |
| @@ -14,13 +14,21 @@ const GENERIC_NAMESPACE_SUFFIXES = new Set([ · 5 unmodified lines | ||
| 14 | 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 | 25 | function normalizeHint(type?: string): string { |
| 18 | 26 | return type && type.trim().length > 0 ? type : "unknown"; |
| 19 | 27 | } |
| 20 | 28 | |
| 21 | 29 | function isEmptyObjectSchema(schema: Record<string, unknown>): boolean { |
| 22 | 30 | if (Object.keys(schema).length === 0) return true; |
| 23 | − const props = asRecord(schema.properties); | |
| 31 | + const props = coerceRecord(schema.properties); | |
| 24 | 32 | const required = Array.isArray(schema.required) ? schema.required : []; |
| 25 | 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 | 112 | const preferredPath = preferredToolPath(tool.path); |
| 105 | 113 | const aliases = getPathAliases(tool.path); |
| 106 | 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); | |
| 109 | − const outputSchema = asRecord(tool.typing?.outputSchema); | |
| 117 | + const inputSchema = coerceRecord(typing?.inputSchema); | |
| 118 | + const outputSchema = coerceRecord(typing?.outputSchema); | |
| 110 | 119 | |
| 111 | 120 | const safeInputSchema = sanitizeJsonSchemaForConvex(inputSchema); |
| 112 | 121 | const safeOutputSchema = sanitizeJsonSchemaForConvex(outputSchema); |
| 113 | 122 | |
| 114 | − const requiredInputKeys = Array.isArray(tool.typing?.requiredInputKeys) | |
| 115 | − ? tool.typing!.requiredInputKeys!.filter((value): value is string => typeof value === "string") | |
| 123 | + const parsedRequiredInputKeys = stringArraySchema.safeParse(typing?.requiredInputKeys); | |
| 124 | + const requiredInputKeys = parsedRequiredInputKeys.success | |
| 125 | + ? parsedRequiredInputKeys.data | |
| 116 | 126 | : extractTopLevelRequiredKeys(inputSchema); |
| 117 | − const previewInputKeys = Array.isArray(tool.typing?.previewInputKeys) | |
| 118 | − ? tool.typing!.previewInputKeys!.filter((value): value is string => typeof value === "string") | |
| 127 | + const parsedPreviewInputKeys = stringArraySchema.safeParse(typing?.previewInputKeys); | |
| 128 | + const previewInputKeys = parsedPreviewInputKeys.success | |
| 129 | + ? parsedPreviewInputKeys.data | |
| 119 | 130 | : buildPreviewKeys(inputSchema); |
| 120 | 131 | |
| 121 | 132 | const displayInputHint = normalizeHint( |
| @@ -19,7 +19,6 @@ import { sanitizeSegment } from "../tool/path-utils"; · 18 unmodified lines | ||
| 19 | 19 | import type { GraphqlToolSourceConfig } from "../tool/source-types"; |
| 20 | 20 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils"; |
| 21 | 21 | import type { ToolDefinition } from "../types"; |
| 22 | −import { asRecord } from "../utils"; | |
| 23 | 22 | |
| 24 | 23 | const gqlTypeRefSchema: z.ZodType<GqlTypeRef> = z.lazy(() => z.object({ |
| 25 | 24 | kind: z.string(), |
| @@ -53,7 +52,7 @@ const gqlEnumValueSchema = z.object({ · 27 unmodified lines | ||
| 53 | 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 | 56 | kind: z.string(), |
| 58 | 57 | name: z.string(), |
| 59 | 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 | 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 | 64 | queryType: z.object({ name: z.string() }).nullable().optional().transform((value) => value ?? null), |
| 66 | 65 | mutationType: z.object({ name: z.string() }).nullable().optional().transform((value) => value ?? null), |
| 67 | 66 | types: z.array(gqlTypeSchema), |
| @@ -72,6 +71,59 @@ const gqlIntrospectionResponseSchema = z.object({ · 4 unmodified lines | ||
| 72 | 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 | 127 | const INTROSPECTION_QUERY = ` |
| 76 | 128 | query IntrospectionQuery { |
| 77 | 129 | __schema { |
| @@ -220,7 +272,7 @@ function parseGraphqlIntrospectionSchema(payload: unknown): Result<GqlSchema, Er · 142 unmodified lines | ||
| 220 | 272 | } |
| 221 | 273 | |
| 222 | 274 | function toGraphqlEnvelope(value: unknown): GraphqlExecutionEnvelope { |
| 223 | − const payload = asRecord(value); | |
| 275 | + const payload = coerceRecord(value); | |
| 224 | 276 | return { |
| 225 | 277 | data: Object.prototype.hasOwnProperty.call(payload, "data") ? payload.data : null, |
| 226 | 278 | errors: Array.isArray(payload.errors) ? payload.errors : [], |
| @@ -248,15 +300,15 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 21 unmodified lines | ||
| 248 | 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()); | |
| 252 | − if (introspectionJsonResult.isErr()) { | |
| 253 | − const message = introspectionJsonResult.error.cause instanceof Error | |
| 254 | − ? introspectionJsonResult.error.cause.message | |
| 255 | − : String(introspectionJsonResult.error.cause); | |
| 303 | + let introspectionJson: unknown; | |
| 304 | + try { | |
| 305 | + introspectionJson = await introspectionResult.json(); | |
| 306 | + } catch (error) { | |
| 307 | + const message = error instanceof Error ? error.message : String(error); | |
| 256 | 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 | 312 | if (schemaResult.isErr()) { |
| 261 | 313 | throw schemaResult.error; |
| 262 | 314 | } |
| @@ -306,18 +358,15 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 43 unmodified lines | ||
| 306 | 358 | authHeaders, |
| 307 | 359 | }, |
| 308 | 360 | run: async (input: unknown, context) => { |
| 309 | − const payload = asRecord(input); | |
| 310 | − const query = String(payload.query ?? ""); | |
| 311 | − const variables = payload.variables ?? undefined; | |
| 312 | − | |
| 313 | − if (!query.trim()) { | |
| 361 | + const normalized = normalizeGraphqlToolInput(input); | |
| 362 | + if (!normalized.hasExplicitQuery) { | |
| 314 | 363 | throw new Error("GraphQL query string is required"); |
| 315 | 364 | } |
| 316 | 365 | return await executeGraphqlRequest( |
| 317 | 366 | config.endpoint, |
| 318 | 367 | authHeaders, |
| 319 | − query, | |
| 320 | − variables, | |
| 368 | + normalized.query, | |
| 369 | + normalized.variables, | |
| 321 | 370 | context.credential?.headers, |
| 322 | 371 | ); |
| 323 | 372 | }, |
| @@ -389,9 +438,9 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 65 unmodified lines | ||
| 389 | 438 | _pseudoTool: true, |
| 390 | 439 | run: async (input: unknown, context) => { |
| 391 | 440 | // If someone calls this directly, delegate to the main graphql tool |
| 392 | − const payload = asRecord(input); | |
| 393 | − const hasExplicitQuery = typeof payload.query === "string" && payload.query.trim().length > 0; | |
| 394 | − if (!hasExplicitQuery) { | |
| 441 | + const normalized = normalizeGraphqlToolInput(input); | |
| 442 | + const payload = normalized.payload; | |
| 443 | + if (!normalized.hasExplicitQuery) { | |
| 395 | 444 | // Auto-build the query from the variables |
| 396 | 445 | payload.query = buildFieldQuery(operationType, field.name, field.args, field.type, typeMap); |
| 397 | 446 | if (payload.variables === undefined) { |
| @@ -8,13 +8,19 @@ import { sanitizeSegment } from "../tool/path-utils"; · 7 unmodified lines | ||
| 8 | 8 | import type { McpToolSourceConfig } from "../tool/source-types"; |
| 9 | 9 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils"; |
| 10 | 10 | import type { ToolDefinition } from "../types"; |
| 11 | −import { asRecord } from "../utils"; | |
| 12 | 11 | import type { SerializedTool } from "../tool/source-serialization"; |
| 13 | 12 | |
| 14 | 13 | const listedToolsResponseSchema = z.object({ |
| 15 | 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 | 24 | function extractListedTools(value: unknown): Record<string, unknown>[] { |
| 19 | 25 | const parsed = listedToolsResponseSchema.safeParse(value); |
| 20 | 26 | if (!parsed.success) { |
| @@ -68,8 +74,8 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 47 unmodified lines | ||
| 68 | 74 | |
| 69 | 75 | return tools.map((tool) => { |
| 70 | 76 | const toolName = String(tool.name ?? "tool"); |
| 71 | − const inputSchema = asRecord(tool.inputSchema); | |
| 72 | − const outputSchema = asRecord(tool.outputSchema); | |
| 77 | + const inputSchema = coerceRecord(tool.inputSchema); | |
| 78 | + const outputSchema = coerceRecord(tool.outputSchema); | |
| 73 | 79 | const previewInputKeys = buildPreviewKeys(inputSchema).filter((key) => key.length > 0); |
| 74 | 80 | const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema); |
| 75 | 81 | return { |
| @@ -93,7 +99,7 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 17 unmodified lines | ||
| 93 | 99 | toolName, |
| 94 | 100 | }, |
| 95 | 101 | run: async (input: unknown, context) => { |
| 96 | − const payload = asRecord(input); | |
| 102 | + const payload = coerceRecord(input); | |
| 97 | 103 | const result = await callToolWithReconnect(toolName, payload, context.credential?.headers); |
| 98 | 104 | return extractMcpResult(result); |
| 99 | 105 | }, |
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | "use node"; |
| 2 | 2 | |
| 3 | −import { Result } from "better-result"; | |
| 4 | 3 | import { z } from "zod"; |
| 5 | 4 | import { buildOpenApiToolsFromPrepared } from "../openapi/tool-builder"; |
| 6 | 5 | import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth"; |
| @@ -16,7 +15,7 @@ import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman · 9 unmodified lines | ||
| 16 | 15 | import { prepareOpenApiSpec } from "../openapi-prepare"; |
| 17 | 16 | import type { OpenApiToolSourceConfig } from "../tool/source-types"; |
| 18 | 17 | import type { ToolDefinition } from "../types"; |
| 19 | −import { asRecord } from "../utils"; | |
| 18 | +import { toPlainObject } from "../utils"; | |
| 20 | 19 | import type { SerializedTool } from "../tool/source-serialization"; |
| 21 | 20 | |
| 22 | 21 | const POSTMAN_SPEC_PREFIX = "postman:"; |
| @@ -30,6 +29,29 @@ const postmanCollectionResponseSchema = z.object({ · 7 unmodified lines | ||
| 30 | 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 | 55 | function parsePostmanCollectionUid(spec: string): string | null { |
| 34 | 56 | if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) { |
| 35 | 57 | return null; |
| @@ -67,14 +89,15 @@ async function loadPostmanCollectionTools( · 31 unmodified lines | ||
| 67 | 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()); | |
| 71 | − if (responseJsonResult.isErr()) { | |
| 72 | − const cause = responseJsonResult.error.cause; | |
| 73 | − const message = cause instanceof Error ? cause.message : String(cause); | |
| 92 | + let responseJson: unknown; | |
| 93 | + try { | |
| 94 | + responseJson = await response.json(); | |
| 95 | + } catch (error) { | |
| 96 | + const message = error instanceof Error ? error.message : String(error); | |
| 74 | 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 | 101 | if (!parsedCollection.success) { |
| 79 | 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 | 111 | const folderById = new Map<string, { name: string; parentId?: string }>(); |
| 89 | 112 | for (const folder of folders) { |
| 90 | − const id = typeof folder.id === "string" ? folder.id : ""; | |
| 91 | − if (!id) continue; | |
| 92 | − const name = typeof folder.name === "string" && folder.name.trim().length > 0 ? folder.name : "folder"; | |
| 93 | − const parentId = typeof folder.folder === "string" ? folder.folder : undefined; | |
| 94 | − folderById.set(id, { name, parentId }); | |
| 113 | + const parsedFolder = postmanFolderSchema.safeParse(folder); | |
| 114 | + if (!parsedFolder.success) continue; | |
| 115 | + | |
| 116 | + const id = parsedFolder.data.id; | |
| 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 | 123 | const sourceLabel = `catalog:${config.name}`; |
| @@ -115,29 +141,32 @@ async function loadPostmanCollectionTools( · 17 unmodified lines | ||
| 115 | 141 | const tools: ToolDefinition[] = []; |
| 116 | 142 | |
| 117 | 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 | 148 | const method = methodRaw.length > 0 ? methodRaw : "get"; |
| 120 | − const url = typeof request.url === "string" ? request.url : ""; | |
| 149 | + const url = parsedRequest.data.url; | |
| 121 | 150 | if (!url) continue; |
| 122 | 151 | |
| 123 | − const requestId = typeof request.id === "string" ? request.id : ""; | |
| 124 | − const requestName = typeof request.name === "string" && request.name.trim().length > 0 | |
| 125 | − ? request.name.trim() | |
| 152 | + const requestId = parsedRequest.data.id ?? ""; | |
| 153 | + const requestName = parsedRequest.data.name && parsedRequest.data.name.trim().length > 0 | |
| 154 | + ? parsedRequest.data.name.trim() | |
| 126 | 155 | : requestId || `${method.toUpperCase()} request`; |
| 127 | − const folderId = typeof request.folder === "string" ? request.folder : undefined; | |
| 156 | + const folderId = parsedRequest.data.folder; | |
| 128 | 157 | const folderPath = resolvePostmanFolderPath(folderId, folderById); |
| 129 | 158 | const requestVariables = { |
| 130 | 159 | ...collectionVariables, |
| 131 | − ...extractPostmanVariableMap(request.pathVariableData), | |
| 160 | + ...extractPostmanVariableMap(parsedRequest.data.pathVariableData ?? parsedRequest.data.pathVariables), | |
| 132 | 161 | }; |
| 133 | 162 | |
| 134 | 163 | const runSpec: PostmanSerializedRunSpec = { |
| 135 | 164 | kind: "postman", |
| 136 | 165 | method, |
| 137 | 166 | url, |
| 138 | − headers: extractPostmanHeaderMap(request.headerData), | |
| 139 | − queryParams: extractPostmanQueryEntries(request.queryParams), | |
| 140 | − body: extractPostmanBody(request), | |
| 167 | + headers: extractPostmanHeaderMap(parsedRequest.data.headerData ?? parsedRequest.data.headers), | |
| 168 | + queryParams: extractPostmanQueryEntries(parsedRequest.data.queryParams ?? parsedRequest.data.queryParam), | |
| 169 | + body: extractPostmanBody(parsedRequest.data.bodyData ?? parsedRequest.data.body), | |
| 141 | 170 | variables: requestVariables, |
| 142 | 171 | authHeaders, |
| 143 | 172 | }; |
| @@ -152,8 +181,8 @@ async function loadPostmanCollectionTools( · 8 unmodified lines | ||
| 152 | 181 | path: buildPostmanToolPath(config.name, requestName, folderPath, usedPaths), |
| 153 | 182 | source: sourceLabel, |
| 154 | 183 | approval, |
| 155 | − description: typeof request.description === "string" && request.description.trim().length > 0 | |
| 156 | − ? request.description | |
| 184 | + description: parsedRequest.data.description && parsedRequest.data.description.trim().length > 0 | |
| 185 | + ? parsedRequest.data.description | |
| 157 | 186 | : `${method.toUpperCase()} ${url}`, |
| 158 | 187 | typing: { |
| 159 | 188 | inputSchema, |
| @@ -163,7 +192,7 @@ async function loadPostmanCollectionTools( · 3 unmodified lines | ||
| 163 | 192 | credential: credentialSpec, |
| 164 | 193 | _runSpec: runSpec, |
| 165 | 194 | run: async (input: unknown, context) => { |
| 166 | − const payloadRecord = asRecord(input); | |
| 195 | + const payloadRecord = toPlainObject(input) ?? {}; | |
| 167 | 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 | 532 | test("parseGraphqlOperationPaths handles aliases and fragments", async () => { |
| 452 | 533 | const parsed = parseGraphqlOperationPaths( |
| 453 | 534 | "linear", |
| @@ -708,6 +789,7 @@ test("graphql helper tools generate valid selection sets and envelope responses" · 254 unmodified lines | ||
| 708 | 789 | const batchResult = await batchTool!.run({ input: { issues: ["one"] } }, context); |
| 709 | 790 | const batchResultDoubleWrapped = await batchTool!.run({ input: { input: { issues: ["two"] } } }, context); |
| 710 | 791 | const rawResult = await rawTool!.run({ query: "query { teams { totalCount } }" }, context); |
| 792 | + const rawStringResult = await rawTool!.run("query { teams { totalCount } }", context); | |
| 711 | 793 | |
| 712 | 794 | expect(teamsResult).toEqual({ |
| 713 | 795 | data: { |
| @@ -739,6 +821,15 @@ test("graphql helper tools generate valid selection sets and envelope responses" · 25 unmodified lines | ||
| 739 | 821 | }, |
| 740 | 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 | 834 | const helperQueries = capturedQueries.filter((query) => !query.includes("__schema")); |
| 744 | 835 | const helperVariables = capturedVariables.slice(1); |
| @@ -1,5 +1,9 @@ | ||
| 1 | 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 | 8 | function stripDollarKeysDeep(value: unknown): unknown { |
| 5 | 9 | if (Array.isArray(value)) { |
| @@ -8,7 +12,7 @@ function stripDollarKeysDeep(value: unknown): unknown { · 2 unmodified lines | ||
| 8 | 12 | |
| 9 | 13 | if (value && typeof value === "object") { |
| 10 | 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 | 16 | if (key.startsWith("$")) continue; |
| 13 | 17 | out[key] = stripDollarKeysDeep(child); |
| 14 | 18 | } |
| @@ -1,5 +1,9 @@ | ||
| 1 | 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 | 8 | function uniq(values: string[]): string[] { |
| 5 | 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 | 21 | const allOf = asUnknownArray(schema.allOf); |
| 18 | 22 | for (const entry of allOf) { |
| 19 | − const nested = asRecord(entry); | |
| 23 | + const nested = toRecord(entry); | |
| 20 | 24 | if (Object.keys(nested).length === 0) continue; |
| 21 | 25 | collectTopLevelRequiredKeys(nested, out); |
| 22 | 26 | } |
| 23 | 27 | } |
| 24 | 28 | |
| 25 | 29 | function collectTopLevelPropertyKeys(schema: Record<string, unknown>, out: string[]): void { |
| 26 | − const props = asRecord(schema.properties); | |
| 30 | + const props = toRecord(schema.properties); | |
| 27 | 31 | out.push(...Object.keys(props)); |
| 28 | 32 | |
| 29 | 33 | const allOf = asUnknownArray(schema.allOf); |
| 30 | 34 | for (const entry of allOf) { |
| 31 | − const nested = asRecord(entry); | |
| 35 | + const nested = toRecord(entry); | |
| 32 | 36 | if (Object.keys(nested).length === 0) continue; |
| 33 | 37 | collectTopLevelPropertyKeys(nested, out); |
| 34 | 38 | } |
| @@ -1,9 +1,13 @@ | ||
| 1 | 1 | import type { ToolDefinition } from "../types"; |
| 2 | 2 | import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints"; |
| 3 | 3 | import { OPENAPI_HELPER_TYPES } from "../openapi/helper-types"; |
| 4 | −import { asRecord } from "../utils"; | |
| 4 | +import { isPlainObject } from "../utils"; | |
| 5 | 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 | 11 | const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i; |
| 8 | 12 | |
| 9 | 13 | function emitMemberName(name: string): string { |
| @@ -31,7 +35,7 @@ function wrapDtsInNamespace(namespace: string, rawDts: string): string { · 21 unmodified lines | ||
| 31 | 35 | function typeHintFromSchema(schema: Record<string, unknown> | undefined, fallback: string): string { |
| 32 | 36 | if (!schema || Object.keys(schema).length === 0) return fallback; |
| 33 | 37 | // Special-case empty object input to keep signatures tidy. |
| 34 | − const props = asRecord(schema.properties); | |
| 38 | + const props = toRecord(schema.properties); | |
| 35 | 39 | const required = Array.isArray(schema.required) ? schema.required : []; |
| 36 | 40 | if (Object.keys(props).length === 0 && required.length === 0) { |
| 37 | 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 | 1 | import type { OpenApiAuth } from "./source-types"; |
| 2 | 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 | 87 | export function buildStaticAuthHeaders(auth?: OpenApiAuth): Record<string, string> { |
| 5 | 88 | if (!auth || auth.type === "none") return {}; |
| @@ -1,5 +1,12 @@ | ||
| 1 | 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 | 11 | type OpenApiRequestErrorArgs = { |
| 5 | 12 | status: number | null; |
| @@ -88,7 +95,7 @@ export async function executeOpenApiRequest( · 82 unmodified lines | ||
| 88 | 95 | input: unknown, |
| 89 | 96 | credentialHeaders?: Record<string, string>, |
| 90 | 97 | ): Promise<Result<unknown, OpenApiRequestError>> { |
| 91 | − const payload = asRecord(input); | |
| 98 | + const payload = toRecord(input); | |
| 92 | 99 | const readMethods = new Set(["get", "head", "options"]); |
| 93 | 100 | const { url, bodyInput } = buildOpenApiUrl( |
| 94 | 101 | runSpec.baseUrl, |
| @@ -154,7 +161,7 @@ export async function executeOpenApiRequest( · 59 unmodified lines | ||
| 154 | 161 | function hasGraphqlData(data: unknown): boolean { |
| 155 | 162 | if (data === null || data === undefined) return false; |
| 156 | 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 | 165 | return true; |
| 159 | 166 | } |
| 160 | 167 | |
| @@ -5,7 +5,13 @@ import { callMcpToolWithReconnect, executeGraphqlRequest, executeOpenApiRequest · 4 unmodified lines | ||
| 5 | 5 | import { Result } from "better-result"; |
| 6 | 6 | import { z } from "zod"; |
| 7 | 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 | 16 | export interface SerializedTool { |
| 11 | 17 | path: string; |
| @@ -124,21 +130,62 @@ const toolTypingSchema: z.ZodType<ToolTyping> = z.object({ · 112 unmodified lines | ||
| 124 | 130 | |
| 125 | 131 | const toolCredentialSpecSchema: z.ZodType<ToolCredentialSpec> = z.object({ |
| 126 | 132 | sourceKey: z.string(), |
| 127 | − mode: z.enum(["static", "workspace", "actor"]), | |
| 133 | + mode: z.enum(["workspace", "actor"]), | |
| 128 | 134 | authType: z.enum(["bearer", "apiKey", "basic"]), |
| 129 | 135 | headerName: z.string().optional(), |
| 130 | 136 | staticSecretJson: z.record(z.unknown()).optional(), |
| 131 | 137 | }); |
| 132 | 138 | |
| 133 | −const graphqlRawInputSchema = z.object({ | |
| 134 | − query: z.string(), | |
| 139 | +const graphqlObjectInputSchema = z.object({ | |
| 140 | + query: z.string().optional(), | |
| 135 | 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({ | |
| 139 | − query: z.string().optional(), | |
| 140 | − variables: z.unknown().optional(), | |
| 141 | −}).passthrough(); | |
| 154 | +function normalizeGraphqlInvocationInput(input: unknown): { | |
| 155 | + payload: Record<string, unknown>; | |
| 156 | + query: string; | |
| 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 | 190 | const serializedRunSpecSchema = z.union([ |
| 144 | 191 | openApiRunSpecSchema, |
| @@ -229,16 +276,16 @@ export function rehydrateTools( · 84 unmodified lines | ||
| 229 | 276 | return serialized.map((candidate, index) => { |
| 230 | 277 | const parsed = parseSerializedTool(candidate); |
| 231 | 278 | if (parsed.isErr()) { |
| 232 | − const raw = asRecord(candidate); | |
| 233 | − const path = typeof raw.path === "string" && raw.path.trim().length > 0 | |
| 234 | − ? raw.path | |
| 279 | + const fallback = invalidSerializedToolFallbackSchema.safeParse(candidate); | |
| 280 | + const path = fallback.success && fallback.data.path && fallback.data.path.trim().length > 0 | |
| 281 | + ? fallback.data.path | |
| 235 | 282 | : `invalid_serialized_tool_${index + 1}`; |
| 236 | 283 | |
| 237 | 284 | return { |
| 238 | 285 | path, |
| 239 | 286 | description: "Invalid serialized tool definition", |
| 240 | 287 | approval: "required", |
| 241 | − source: typeof raw.source === "string" ? raw.source : undefined, | |
| 288 | + source: fallback.success ? fallback.data.source : undefined, | |
| 242 | 289 | run: async () => { |
| 243 | 290 | throw new Error(`Invalid serialized tool '${path}': ${parsed.error.message}`); |
| 244 | 291 | }, |
| @@ -282,7 +329,7 @@ export function rehydrateTools( · 37 unmodified lines | ||
| 282 | 329 | return { |
| 283 | 330 | ...base, |
| 284 | 331 | run: async (input: unknown, context) => { |
| 285 | − const payload = asRecord(input); | |
| 332 | + const payload = toRecord(input); | |
| 286 | 333 | return await executePostmanRequest(runSpec, payload, context.credential?.headers); |
| 287 | 334 | }, |
| 288 | 335 | }; |
| @@ -305,7 +352,7 @@ export function rehydrateTools( · 16 unmodified lines | ||
| 305 | 352 | () => connectMcp(url, queryParams, transport, mergedHeaders), |
| 306 | 353 | ); |
| 307 | 354 | |
| 308 | − const payload = asRecord(input); | |
| 355 | + const payload = toRecord(input); | |
| 309 | 356 | const result = await callMcpToolWithReconnect( |
| 310 | 357 | () => conn.client.callTool({ name: toolName, arguments: payload }), |
| 311 | 358 | async () => { |
| @@ -330,14 +377,17 @@ export function rehydrateTools( · 18 unmodified lines | ||
| 330 | 377 | return { |
| 331 | 378 | ...base, |
| 332 | 379 | run: async (input: unknown, context) => { |
| 333 | − const payload = asRecord(input); | |
| 334 | − const parsedInput = graphqlRawInputSchema.safeParse(payload); | |
| 335 | − const query = parsedInput.success ? parsedInput.data.query : ""; | |
| 336 | − if (!query.trim()) { | |
| 380 | + const normalized = normalizeGraphqlInvocationInput(input); | |
| 381 | + if (!normalized.hasExplicitQuery) { | |
| 337 | 382 | throw new Error("GraphQL query string is required"); |
| 338 | 383 | } |
| 339 | − const variables = parsedInput.success ? parsedInput.data.variables : undefined; | |
| 340 | − const response = await executeGraphqlRequest(endpoint, authHeaders, query, variables, context.credential?.headers); | |
| 384 | + const response = await executeGraphqlRequest( | |
| 385 | + endpoint, | |
| 386 | + authHeaders, | |
| 387 | + normalized.query, | |
| 388 | + normalized.variables, | |
| 389 | + context.credential?.headers, | |
| 390 | + ); | |
| 341 | 391 | if (response.isErr()) { |
| 342 | 392 | throw new Error(response.error.message); |
| 343 | 393 | } |
| @@ -351,15 +401,12 @@ export function rehydrateTools( · 7 unmodified lines | ||
| 351 | 401 | return { |
| 352 | 402 | ...base, |
| 353 | 403 | run: async (input: unknown, context) => { |
| 354 | − const payload = asRecord(input); | |
| 355 | − const parsedInput = graphqlFieldInputSchema.safeParse(payload); | |
| 356 | − const normalizedInput = parsedInput.success ? parsedInput.data : {}; | |
| 357 | − const hasExplicitQuery = typeof normalizedInput.query === "string" && normalizedInput.query.trim().length > 0; | |
| 358 | − const query = hasExplicitQuery ? normalizedInput.query : queryTemplate; | |
| 359 | − | |
| 360 | − let variables = normalizedInput.variables; | |
| 361 | − if (variables === undefined && !hasExplicitQuery) { | |
| 362 | − variables = normalizeGraphqlFieldVariables(argNames ?? [], payload); | |
| 404 | + const normalized = normalizeGraphqlInvocationInput(input); | |
| 405 | + const query = normalized.hasExplicitQuery ? normalized.query : queryTemplate; | |
| 406 | + | |
| 407 | + let variables = normalized.variables; | |
| 408 | + if (variables === undefined && !normalized.hasExplicitQuery) { | |
| 409 | + variables = normalizeGraphqlFieldVariables(argNames ?? [], normalized.payload); | |
| 363 | 410 | } |
| 364 | 411 | |
| 365 | 412 | const envelopeResult = await executeGraphqlRequest( |
| @@ -6,6 +6,11 @@ export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_ · 5 unmodified lines | ||
| 6 | 6 | export type ApprovalStatus = "pending" | "approved" | "denied"; |
| 7 | 7 | export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied"; |
| 8 | 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 | 14 | export type CredentialScope = "workspace" | "actor"; |
| 10 | 15 | export type CredentialProvider = "local-convex" | "workos-vault"; |
| 11 | 16 | export type ToolApprovalMode = "auto" | "required"; |
| @@ -87,20 +92,31 @@ export interface ToolCallRecord { · 75 unmodified lines | ||
| 87 | 92 | |
| 88 | 93 | export interface AccessPolicyRecord { |
| 89 | 94 | id: string; |
| 90 | − workspaceId: Id<"workspaces">; | |
| 91 | − actorId?: string; | |
| 95 | + scopeType?: PolicyScopeType; | |
| 96 | + organizationId?: Id<"organizations">; | |
| 97 | + workspaceId?: Id<"workspaces">; | |
| 98 | + targetActorId?: string; | |
| 92 | 99 | clientId?: string; |
| 93 | − toolPathPattern: string; | |
| 94 | − decision: PolicyDecision; | |
| 100 | + resourceType?: "tool_path"; | |
| 101 | + resourcePattern?: string; | |
| 102 | + matchType?: PolicyMatchType; | |
| 103 | + effect?: PolicyEffect; | |
| 104 | + approvalMode?: PolicyApprovalMode; | |
| 95 | 105 | priority: number; |
| 96 | 106 | createdAt: number; |
| 97 | 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 | 114 | export interface CredentialRecord { |
| 101 | 115 | id: string; |
| 102 | 116 | bindingId?: string; |
| 103 | − workspaceId: Id<"workspaces">; | |
| 117 | + ownerScopeType?: OwnerScopeType; | |
| 118 | + organizationId?: Id<"organizations">; | |
| 119 | + workspaceId?: Id<"workspaces">; | |
| 104 | 120 | sourceKey: string; |
| 105 | 121 | scope: CredentialScope; |
| 106 | 122 | actorId?: string; |
| @@ -114,10 +130,14 @@ export interface CredentialRecord { · 7 unmodified lines | ||
| 114 | 130 | |
| 115 | 131 | export interface ToolSourceRecord { |
| 116 | 132 | id: string; |
| 117 | − workspaceId: Id<"workspaces">; | |
| 133 | + ownerScopeType?: OwnerScopeType; | |
| 134 | + organizationId?: Id<"organizations">; | |
| 135 | + workspaceId?: Id<"workspaces">; | |
| 118 | 136 | name: string; |
| 119 | 137 | type: ToolSourceType; |
| 120 | 138 | config: Record<string, unknown>; |
| 139 | + specHash?: string; | |
| 140 | + authFingerprint?: string; | |
| 121 | 141 | enabled: boolean; |
| 122 | 142 | createdAt: number; |
| 123 | 143 | updatedAt: number; |
| @@ -1,12 +1,18 @@ | ||
| 1 | 1 | /** |
| 2 | − * Safely cast an unknown value to a Record<string, unknown>. | |
| 3 | − * Returns an empty object for nullish, non-object, or array values. | |
| 2 | + * Runtime guard for plain object payloads. | |
| 4 | 3 | */ |
| 5 | −export function asRecord(value: unknown): Record<string, unknown> { | |
| 6 | − if (value && typeof value === "object" && !Array.isArray(value)) { | |
| 7 | − return value as Record<string, unknown>; | |
| 8 | − } | |
| 9 | − return {}; | |
| 4 | +export function isPlainObject(value: unknown): value is Record<string, unknown> { | |
| 5 | + if (!value || typeof value !== "object" || Array.isArray(value)) return false; | |
| 6 | + | |
| 7 | + const prototype = Object.getPrototypeOf(value); | |
| 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 | 5 | * round-trip with functional `run` methods. |
| 6 | 6 | */ |
| 7 | 7 | import { test, expect, describe } from "bun:test"; |
| 8 | +import { z } from "zod"; | |
| 8 | 9 | import type { Id } from "../../../database/convex/_generated/dataModel"; |
| 9 | 10 | import { |
| 10 | 11 | prepareOpenApiSpec, |
| @@ -20,6 +21,12 @@ import type { ToolDefinition } from "../types"; · 9 unmodified lines | ||
| 20 | 21 | |
| 21 | 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 | 30 | function makeBaseTools(): Map<string, ToolDefinition> { |
| 24 | 31 | return new Map([ |
| 25 | 32 | [ |
| @@ -36,7 +43,8 @@ function makeBaseTools(): Map<string, ToolDefinition> { · 10 unmodified lines | ||
| 36 | 43 | previewInputKeys: ["message"], |
| 37 | 44 | }, |
| 38 | 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 | 48 | return payload.message; |
| 41 | 49 | }, |
| 42 | 50 | }, |
| @@ -338,7 +346,8 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 295 unmodified lines | ||
| 338 | 346 | const originalFetch = globalThis.fetch; |
| 339 | 347 | const calls: Array<{ query: string; variables: unknown }> = []; |
| 340 | 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 | 351 | calls.push({ query: body.query ?? "", variables: body.variables }); |
| 343 | 352 | |
| 344 | 353 | if ((body.query ?? "").includes("teams")) { |
| @@ -359,14 +368,19 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 14 unmodified lines | ||
| 359 | 368 | { query: "query viewer { viewer { id } }" }, |
| 360 | 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 | 375 | const teamsResult = await teamsTool!.run( |
| 363 | 376 | {}, |
| 364 | 377 | { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true }, |
| 365 | 378 | ); |
| 366 | 379 | |
| 367 | 380 | expect(rawResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] }); |
| 381 | + expect(rawStringResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] }); | |
| 368 | 382 | expect(teamsResult).toEqual({ data: [{ id: "team_1", name: "Core" }], errors: [] }); |
| 369 | − expect(calls.length).toBe(2); | |
| 383 | + expect(calls.length).toBe(3); | |
| 370 | 384 | } finally { |
| 371 | 385 | globalThis.fetch = originalFetch; |
| 372 | 386 | } |
| @@ -88,9 +88,10 @@ export declare const api: { · 87 unmodified lines | ||
| 88 | 88 | { |
| 89 | 89 | actorId?: string; |
| 90 | 90 | id?: string; |
| 91 | + ownerScopeType?: "organization" | "workspace"; | |
| 91 | 92 | provider?: "local-convex" | "workos-vault"; |
| 92 | 93 | scope: "workspace" | "actor"; |
| 93 | − secretJson: any; | |
| 94 | + secretJson: Record<string, any>; | |
| 94 | 95 | sessionId?: string; |
| 95 | 96 | sourceKey: string; |
| 96 | 97 | workspaceId: Id<"workspaces">; |
| @@ -106,7 +107,7 @@ export declare const api: { · 9 unmodified lines | ||
| 106 | 107 | actorId?: string; |
| 107 | 108 | clientId?: string; |
| 108 | 109 | code: string; |
| 109 | − metadata?: any; | |
| 110 | + metadata?: Record<string, any>; | |
| 110 | 111 | runtimeId?: string; |
| 111 | 112 | sessionId?: string; |
| 112 | 113 | timeoutMs?: number; |
| @@ -268,7 +269,7 @@ export declare const api: { · 155 unmodified lines | ||
| 268 | 269 | "public", |
| 269 | 270 | { |
| 270 | 271 | callId: string; |
| 271 | − input?: any; | |
| 272 | + input?: Record<string, any>; | |
| 272 | 273 | internalSecret: string; |
| 273 | 274 | runId: string; |
| 274 | 275 | toolPath: string; |
| @@ -336,10 +337,14 @@ export declare const api: { · 61 unmodified lines | ||
| 336 | 337 | "public", |
| 337 | 338 | { |
| 338 | 339 | actorId?: string; |
| 340 | + approvalMode?: "inherit" | "auto" | "required"; | |
| 339 | 341 | clientId?: string; |
| 340 | − decision: "allow" | "require_approval" | "deny"; | |
| 342 | + decision?: "allow" | "require_approval" | "deny"; | |
| 343 | + effect?: "allow" | "deny"; | |
| 341 | 344 | id?: string; |
| 345 | + matchType?: "glob" | "exact"; | |
| 342 | 346 | priority?: number; |
| 347 | + scopeType?: "organization" | "workspace"; | |
| 343 | 348 | sessionId?: string; |
| 344 | 349 | toolPathPattern: string; |
| 345 | 350 | workspaceId: Id<"workspaces">; |
| @@ -352,10 +357,11 @@ export declare const api: { · 6 unmodified lines | ||
| 352 | 357 | { |
| 353 | 358 | actorId?: string; |
| 354 | 359 | id?: string; |
| 355 | − overridesJson?: any; | |
| 360 | + overridesJson?: Record<string, any>; | |
| 361 | + ownerScopeType?: "organization" | "workspace"; | |
| 356 | 362 | provider?: "local-convex" | "workos-vault"; |
| 357 | 363 | scope: "workspace" | "actor"; |
| 358 | − secretJson: any; | |
| 364 | + secretJson: Record<string, any>; | |
| 359 | 365 | sessionId?: string; |
| 360 | 366 | sourceKey: string; |
| 361 | 367 | workspaceId: Id<"workspaces">; |
| @@ -366,10 +372,11 @@ export declare const api: { · 4 unmodified lines | ||
| 366 | 372 | "mutation", |
| 367 | 373 | "public", |
| 368 | 374 | { |
| 369 | − config: any; | |
| 375 | + config: Record<string, any>; | |
| 370 | 376 | enabled?: boolean; |
| 371 | 377 | id?: string; |
| 372 | 378 | name: string; |
| 379 | + ownerScopeType?: "organization" | "workspace"; | |
| 373 | 380 | sessionId?: string; |
| 374 | 381 | type: "mcp" | "openapi" | "graphql"; |
| 375 | 382 | workspaceId: Id<"workspaces">; |
| @@ -486,7 +493,12 @@ export declare const internal: { · 110 unmodified lines | ||
| 486 | 493 | createApproval: FunctionReference< |
| 487 | 494 | "mutation", |
| 488 | 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 | 502 | any |
| 491 | 503 | >; |
| 492 | 504 | getApproval: FunctionReference< |
| @@ -537,7 +549,12 @@ export declare const internal: { · 44 unmodified lines | ||
| 537 | 549 | createApproval: FunctionReference< |
| 538 | 550 | "mutation", |
| 539 | 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 | 558 | any |
| 542 | 559 | >; |
| 543 | 560 | createTask: FunctionReference< |
| @@ -548,7 +565,7 @@ export declare const internal: { · 4 unmodified lines | ||
| 548 | 565 | clientId?: string; |
| 549 | 566 | code: string; |
| 550 | 567 | id: string; |
| 551 | − metadata?: any; | |
| 568 | + metadata?: Record<string, any>; | |
| 552 | 569 | runtimeId: string; |
| 553 | 570 | timeoutMs?: number; |
| 554 | 571 | workspaceId: Id<"workspaces">; |
| @@ -558,7 +575,12 @@ export declare const internal: { · 3 unmodified lines | ||
| 558 | 575 | createTaskEvent: FunctionReference< |
| 559 | 576 | "mutation", |
| 560 | 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 | 584 | any |
| 563 | 585 | >; |
| 564 | 586 | credentials: { |
| @@ -586,10 +608,11 @@ export declare const internal: { · 21 unmodified lines | ||
| 586 | 608 | { |
| 587 | 609 | actorId?: string; |
| 588 | 610 | id?: string; |
| 589 | − overridesJson?: any; | |
| 611 | + overridesJson?: Record<string, any>; | |
| 612 | + ownerScopeType?: "organization" | "workspace"; | |
| 590 | 613 | provider?: "local-convex" | "workos-vault"; |
| 591 | 614 | scope: "workspace" | "actor"; |
| 592 | − secretJson: any; | |
| 615 | + secretJson: Record<string, any>; | |
| 593 | 616 | sourceKey: string; |
| 594 | 617 | workspaceId: Id<"workspaces">; |
| 595 | 618 | }, |
| @@ -733,11 +756,17 @@ export declare const internal: { · 137 unmodified lines | ||
| 733 | 756 | "internal", |
| 734 | 757 | { |
| 735 | 758 | actorId?: string; |
| 759 | + approvalMode?: "inherit" | "auto" | "required"; | |
| 736 | 760 | clientId?: string; |
| 737 | − decision: "allow" | "require_approval" | "deny"; | |
| 761 | + decision?: "allow" | "require_approval" | "deny"; | |
| 762 | + effect?: "allow" | "deny"; | |
| 738 | 763 | id?: string; |
| 764 | + matchType?: "glob" | "exact"; | |
| 739 | 765 | priority?: number; |
| 740 | − toolPathPattern: string; | |
| 766 | + resourcePattern?: string; | |
| 767 | + scopeType?: "organization" | "workspace"; | |
| 768 | + targetActorId?: string; | |
| 769 | + toolPathPattern?: string; | |
| 741 | 770 | workspaceId: Id<"workspaces">; |
| 742 | 771 | }, |
| 743 | 772 | any |
| @@ -775,7 +804,12 @@ export declare const internal: { · 31 unmodified lines | ||
| 775 | 804 | createTaskEvent: FunctionReference< |
| 776 | 805 | "mutation", |
| 777 | 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 | 813 | any |
| 780 | 814 | >; |
| 781 | 815 | listTaskEvents: FunctionReference< |
| @@ -794,7 +828,7 @@ export declare const internal: { · 12 unmodified lines | ||
| 794 | 828 | clientId?: string; |
| 795 | 829 | code: string; |
| 796 | 830 | id: string; |
| 797 | − metadata?: any; | |
| 831 | + metadata?: Record<string, any>; | |
| 798 | 832 | runtimeId: string; |
| 799 | 833 | timeoutMs?: number; |
| 800 | 834 | workspaceId: Id<"workspaces">; |
| @@ -897,10 +931,11 @@ export declare const internal: { · 96 unmodified lines | ||
| 897 | 931 | "mutation", |
| 898 | 932 | "internal", |
| 899 | 933 | { |
| 900 | − config: any; | |
| 934 | + config: Record<string, any>; | |
| 901 | 935 | enabled?: boolean; |
| 902 | 936 | id?: string; |
| 903 | 937 | name: string; |
| 938 | + ownerScopeType?: "organization" | "workspace"; | |
| 904 | 939 | type: "mcp" | "openapi" | "graphql"; |
| 905 | 940 | workspaceId: Id<"workspaces">; |
| 906 | 941 | }, |
| @@ -912,11 +947,17 @@ export declare const internal: { · 5 unmodified lines | ||
| 912 | 947 | "internal", |
| 913 | 948 | { |
| 914 | 949 | actorId?: string; |
| 950 | + approvalMode?: "inherit" | "auto" | "required"; | |
| 915 | 951 | clientId?: string; |
| 916 | − decision: "allow" | "require_approval" | "deny"; | |
| 952 | + decision?: "allow" | "require_approval" | "deny"; | |
| 953 | + effect?: "allow" | "deny"; | |
| 917 | 954 | id?: string; |
| 955 | + matchType?: "glob" | "exact"; | |
| 918 | 956 | priority?: number; |
| 919 | − toolPathPattern: string; | |
| 957 | + resourcePattern?: string; | |
| 958 | + scopeType?: "organization" | "workspace"; | |
| 959 | + targetActorId?: string; | |
| 960 | + toolPathPattern?: string; | |
| 920 | 961 | workspaceId: Id<"workspaces">; |
| 921 | 962 | }, |
| 922 | 963 | any |
| @@ -927,10 +968,11 @@ export declare const internal: { · 4 unmodified lines | ||
| 927 | 968 | { |
| 928 | 969 | actorId?: string; |
| 929 | 970 | id?: string; |
| 930 | − overridesJson?: any; | |
| 971 | + overridesJson?: Record<string, any>; | |
| 972 | + ownerScopeType?: "organization" | "workspace"; | |
| 931 | 973 | provider?: "local-convex" | "workos-vault"; |
| 932 | 974 | scope: "workspace" | "actor"; |
| 933 | − secretJson: any; | |
| 975 | + secretJson: Record<string, any>; | |
| 934 | 976 | sourceKey: string; |
| 935 | 977 | workspaceId: Id<"workspaces">; |
| 936 | 978 | }, |
| @@ -951,10 +993,11 @@ export declare const internal: { · 14 unmodified lines | ||
| 951 | 993 | "mutation", |
| 952 | 994 | "internal", |
| 953 | 995 | { |
| 954 | − config: any; | |
| 996 | + config: Record<string, any>; | |
| 955 | 997 | enabled?: boolean; |
| 956 | 998 | id?: string; |
| 957 | 999 | name: string; |
| 1000 | + ownerScopeType?: "organization" | "workspace"; | |
| 958 | 1001 | type: "mcp" | "openapi" | "graphql"; |
| 959 | 1002 | workspaceId: Id<"workspaces">; |
| 960 | 1003 | }, |
| @@ -981,7 +1024,7 @@ export declare const internal: { · 20 unmodified lines | ||
| 981 | 1024 | actorId: string; |
| 982 | 1025 | clientId?: string; |
| 983 | 1026 | code: string; |
| 984 | − metadata?: any; | |
| 1027 | + metadata?: Record<string, any>; | |
| 985 | 1028 | runtimeId?: string; |
| 986 | 1029 | scheduleAfterCreate?: boolean; |
| 987 | 1030 | timeoutMs?: number; |
| @@ -1006,7 +1049,12 @@ export declare const internal: { · 18 unmodified lines | ||
| 1006 | 1049 | handleExternalToolCall: FunctionReference< |
| 1007 | 1050 | "action", |
| 1008 | 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 | 1058 | any |
| 1011 | 1059 | >; |
| 1012 | 1060 | listToolsInternal: FunctionReference< |
| @@ -1258,6 +1306,12 @@ export declare const internal: { · 245 unmodified lines | ||
| 1258 | 1306 | }, |
| 1259 | 1307 | any |
| 1260 | 1308 | >; |
| 1309 | + pruneBuilds: FunctionReference< | |
| 1310 | + "mutation", | |
| 1311 | + "internal", | |
| 1312 | + { maxRetainedBuilds?: number; workspaceId: Id<"workspaces"> }, | |
| 1313 | + any | |
| 1314 | + >; | |
| 1261 | 1315 | putNamespacesBatch: FunctionReference< |
| 1262 | 1316 | "mutation", |
| 1263 | 1317 | "internal", |
| @@ -8,12 +8,14 @@ import { jsonObjectValidator } from "../src/database/validators"; · 7 unmodified lines | ||
| 8 | 8 | |
| 9 | 9 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); |
| 10 | 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 | 13 | export const upsertCredential = action({ |
| 13 | 14 | args: { |
| 14 | 15 | id: v.optional(v.string()), |
| 15 | 16 | workspaceId: v.id("workspaces"), |
| 16 | 17 | sessionId: v.optional(v.string()), |
| 18 | + ownerScopeType: v.optional(ownerScopeTypeValidator), | |
| 17 | 19 | sourceKey: v.string(), |
| 18 | 20 | scope: credentialScopeValidator, |
| 19 | 21 | actorId: v.optional(v.string()), |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { v } from "convex/values"; |
| 2 | +import { z } from "zod"; | |
| 2 | 3 | import { internalMutation, internalQuery } from "../_generated/server"; |
| 3 | 4 | import { mapCredential } from "../../src/database/mappers"; |
| 4 | 5 | import { computeBoundAuthFingerprint } from "../../src/database/readers"; |
| @@ -6,8 +7,15 @@ import { · 1 unmodified line | ||
| 6 | 7 | credentialProviderValidator, |
| 7 | 8 | credentialScopeValidator, |
| 8 | 9 | jsonObjectValidator, |
| 10 | + ownerScopeTypeValidator, | |
| 9 | 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 | 20 | function scopeKeyForCredential(scope: "workspace" | "actor", actorId?: string): string { |
| 13 | 21 | if (scope === "workspace") { |
| @@ -26,6 +34,7 @@ export const upsertCredential = internalMutation({ · 12 unmodified lines | ||
| 26 | 34 | args: { |
| 27 | 35 | id: v.optional(v.string()), |
| 28 | 36 | workspaceId: v.id("workspaces"), |
| 37 | + ownerScopeType: v.optional(ownerScopeTypeValidator), | |
| 29 | 38 | sourceKey: v.string(), |
| 30 | 39 | scope: credentialScopeValidator, |
| 31 | 40 | actorId: v.optional(v.string()), |
| @@ -35,20 +44,39 @@ export const upsertCredential = internalMutation({ · 3 unmodified lines | ||
| 35 | 44 | }, |
| 36 | 45 | handler: async (ctx, args) => { |
| 37 | 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 | 55 | const actorId = args.scope === "actor" ? args.actorId?.trim() : undefined; |
| 39 | 56 | const scopeKey = scopeKeyForCredential(args.scope, actorId); |
| 40 | − const submittedSecret = asRecord(args.secretJson); | |
| 57 | + const submittedSecret = toRecordValue(args.secretJson); | |
| 41 | 58 | const hasSubmittedSecret = Object.keys(submittedSecret).length > 0; |
| 42 | 59 | |
| 43 | − const existing = await ctx.db | |
| 44 | − .query("sourceCredentials") | |
| 45 | − .withIndex("by_workspace_source_scope_key", (q) => | |
| 46 | − q | |
| 47 | − .eq("workspaceId", args.workspaceId) | |
| 48 | − .eq("sourceKey", args.sourceKey) | |
| 49 | − .eq("scopeKey", scopeKey), | |
| 50 | − ) | |
| 51 | − .unique(); | |
| 60 | + const existing = ownerScopeType === "workspace" | |
| 61 | + ? await ctx.db | |
| 62 | + .query("sourceCredentials") | |
| 63 | + .withIndex("by_workspace_source_scope_key", (q) => | |
| 64 | + q | |
| 65 | + .eq("workspaceId", args.workspaceId) | |
| 66 | + .eq("sourceKey", args.sourceKey) | |
| 67 | + .eq("scopeKey", scopeKey), | |
| 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 | 81 | let requestedId = args.id?.trim() || ""; |
| 54 | 82 | if (requestedId.startsWith("bind_")) { |
| @@ -56,31 +84,42 @@ export const upsertCredential = internalMutation({ · 1 unmodified line | ||
| 56 | 84 | .query("sourceCredentials") |
| 57 | 85 | .withIndex("by_binding_id", (q) => q.eq("bindingId", requestedId)) |
| 58 | 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 | 92 | requestedId = binding.credentialId; |
| 61 | 93 | } |
| 62 | 94 | } |
| 63 | 95 | |
| 64 | 96 | const connectionId = requestedId || existing?.credentialId || `conn_${crypto.randomUUID()}`; |
| 65 | 97 | |
| 66 | − const linkedRows = await ctx.db | |
| 67 | − .query("sourceCredentials") | |
| 68 | − .withIndex("by_workspace_credential", (q) => | |
| 69 | − q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId), | |
| 70 | − ) | |
| 71 | − .collect(); | |
| 98 | + const linkedRows = ownerScopeType === "workspace" | |
| 99 | + ? await ctx.db | |
| 100 | + .query("sourceCredentials") | |
| 101 | + .withIndex("by_workspace_credential", (q) => | |
| 102 | + q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId), | |
| 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 | 111 | const exemplar = linkedRows[0] ?? existing ?? null; |
| 73 | 112 | |
| 74 | 113 | const provider = args.provider ?? exemplar?.provider ?? "local-convex"; |
| 75 | − const fallbackSecret = asRecord(exemplar?.secretJson); | |
| 114 | + const fallbackSecret = toRecordValue(exemplar?.secretJson); | |
| 76 | 115 | const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret; |
| 77 | 116 | if (Object.keys(finalSecret).length === 0) { |
| 78 | 117 | throw new Error("Credential values are required"); |
| 79 | 118 | } |
| 80 | 119 | |
| 81 | 120 | const overridesJson = args.overridesJson === undefined |
| 82 | − ? asRecord(existing?.overridesJson) | |
| 83 | − : asRecord(args.overridesJson); | |
| 121 | + ? toRecordValue(existing?.overridesJson) | |
| 122 | + : toRecordValue(args.overridesJson); | |
| 84 | 123 | |
| 85 | 124 | const boundAuthFingerprint = await computeBoundAuthFingerprint( |
| 86 | 125 | ctx, |
| @@ -100,6 +139,9 @@ export const upsertCredential = internalMutation({ · 13 unmodified lines | ||
| 100 | 139 | |
| 101 | 140 | if (existing) { |
| 102 | 141 | await ctx.db.patch(existing._id, { |
| 142 | + ownerScopeType, | |
| 143 | + organizationId, | |
| 144 | + workspaceId: ownerWorkspaceId, | |
| 103 | 145 | credentialId: connectionId, |
| 104 | 146 | provider, |
| 105 | 147 | secretJson: finalSecret, |
| @@ -113,7 +155,9 @@ export const upsertCredential = internalMutation({ · 7 unmodified lines | ||
| 113 | 155 | await ctx.db.insert("sourceCredentials", { |
| 114 | 156 | bindingId: `bind_${crypto.randomUUID()}`, |
| 115 | 157 | credentialId: connectionId, |
| 116 | − workspaceId: args.workspaceId, | |
| 158 | + ownerScopeType, | |
| 159 | + organizationId, | |
| 160 | + workspaceId: ownerWorkspaceId, | |
| 117 | 161 | sourceKey: args.sourceKey, |
| 118 | 162 | scope: args.scope, |
| 119 | 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 | |
| 131 | − .query("sourceCredentials") | |
| 132 | − .withIndex("by_workspace_source_scope_key", (q) => | |
| 133 | − q | |
| 134 | − .eq("workspaceId", args.workspaceId) | |
| 135 | − .eq("sourceKey", args.sourceKey) | |
| 136 | − .eq("scopeKey", scopeKey), | |
| 137 | − ) | |
| 138 | − .unique(); | |
| 174 | + const updated = ownerScopeType === "workspace" | |
| 175 | + ? await ctx.db | |
| 176 | + .query("sourceCredentials") | |
| 177 | + .withIndex("by_workspace_source_scope_key", (q) => | |
| 178 | + q | |
| 179 | + .eq("workspaceId", args.workspaceId) | |
| 180 | + .eq("sourceKey", args.sourceKey) | |
| 181 | + .eq("scopeKey", scopeKey), | |
| 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 | 195 | if (!updated) { |
| 141 | 196 | throw new Error("Failed to read upserted credential"); |
| @@ -148,11 +203,30 @@ export const upsertCredential = internalMutation({ · 6 unmodified lines | ||
| 148 | 203 | export const listCredentials = internalQuery({ |
| 149 | 204 | args: { workspaceId: v.id("workspaces") }, |
| 150 | 205 | handler: async (ctx, args) => { |
| 151 | − const docs = await ctx.db | |
| 152 | − .query("sourceCredentials") | |
| 153 | − .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 154 | − .order("desc") | |
| 155 | − .collect(); | |
| 206 | + const workspace = await ctx.db.get(args.workspaceId); | |
| 207 | + if (!workspace) { | |
| 208 | + return []; | |
| 209 | + } | |
| 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 | 230 | return docs.map(mapCredential); |
| 157 | 231 | }, |
| 158 | 232 | }); |
| @@ -181,35 +255,62 @@ export const resolveCredential = internalQuery({ · 22 unmodified lines | ||
| 181 | 255 | actorId: v.optional(v.string()), |
| 182 | 256 | }, |
| 183 | 257 | handler: async (ctx, args) => { |
| 184 | − if (args.scope === "actor") { | |
| 185 | − const actorId = args.actorId?.trim() || ""; | |
| 186 | − if (!actorId) { | |
| 187 | − return null; | |
| 188 | − } | |
| 258 | + const workspace = await ctx.db.get(args.workspaceId); | |
| 259 | + if (!workspace) { | |
| 260 | + return null; | |
| 261 | + } | |
| 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 | 269 | .query("sourceCredentials") |
| 192 | 270 | .withIndex("by_workspace_source_scope_key", (q) => |
| 193 | 271 | q |
| 194 | 272 | .eq("workspaceId", args.workspaceId) |
| 195 | 273 | .eq("sourceKey", args.sourceKey) |
| 196 | − .eq("scopeKey", scopeKeyForCredential("actor", actorId)), | |
| 274 | + .eq("scopeKey", scopeKey), | |
| 197 | 275 | ) |
| 198 | 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 | |
| 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; | |
| 314 | + return null; | |
| 214 | 315 | }, |
| 215 | 316 | }); |
| @@ -2,7 +2,26 @@ import { v } from "convex/values"; · 1 unmodified line | ||
| 2 | 2 | import { internalMutation, internalQuery } from "../_generated/server"; |
| 3 | 3 | import { listRuntimeTargets as listAvailableRuntimeTargets } from "../../../core/src/runtimes/runtime-catalog"; |
| 4 | 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 | 26 | export const listRuntimeTargets = internalQuery({ |
| 8 | 27 | args: {}, |
| @@ -15,15 +34,34 @@ export const upsertAccessPolicy = internalMutation({ · 6 unmodified lines | ||
| 15 | 34 | args: { |
| 16 | 35 | id: v.optional(v.string()), |
| 17 | 36 | workspaceId: v.id("workspaces"), |
| 37 | + scopeType: v.optional(policyScopeTypeValidator), | |
| 18 | 38 | actorId: v.optional(v.string()), |
| 39 | + targetActorId: v.optional(v.string()), | |
| 19 | 40 | clientId: v.optional(v.string()), |
| 20 | − toolPathPattern: v.string(), | |
| 21 | − decision: policyDecisionValidator, | |
| 41 | + toolPathPattern: v.optional(v.string()), | |
| 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 | 47 | priority: v.optional(v.number()), |
| 23 | 48 | }, |
| 24 | 49 | handler: async (ctx, args) => { |
| 25 | 50 | const now = Date.now(); |
| 26 | 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 | 65 | const existing = await ctx.db |
| 28 | 66 | .query("accessPolicies") |
| 29 | 67 | .withIndex("by_policy_id", (q) => q.eq("policyId", policyId)) |
| @@ -31,22 +69,32 @@ export const upsertAccessPolicy = internalMutation({ · 1 unmodified line | ||
| 31 | 69 | |
| 32 | 70 | if (existing) { |
| 33 | 71 | await ctx.db.patch(existing._id, { |
| 34 | − workspaceId: args.workspaceId, | |
| 35 | − actorId: args.actorId?.trim() || undefined, | |
| 72 | + scopeType, | |
| 73 | + organizationId: workspace.organizationId, | |
| 74 | + workspaceId: scopeType === "workspace" ? args.workspaceId : undefined, | |
| 75 | + targetActorId, | |
| 36 | 76 | clientId: args.clientId?.trim() || undefined, |
| 37 | − toolPathPattern: args.toolPathPattern, | |
| 38 | − decision: args.decision, | |
| 77 | + resourceType: "tool_path", | |
| 78 | + resourcePattern, | |
| 79 | + matchType, | |
| 80 | + effect, | |
| 81 | + approvalMode, | |
| 39 | 82 | priority: args.priority ?? 100, |
| 40 | 83 | updatedAt: now, |
| 41 | 84 | }); |
| 42 | 85 | } else { |
| 43 | 86 | await ctx.db.insert("accessPolicies", { |
| 44 | 87 | policyId, |
| 45 | − workspaceId: args.workspaceId, | |
| 46 | − actorId: args.actorId?.trim() || undefined, | |
| 88 | + scopeType, | |
| 89 | + organizationId: workspace.organizationId, | |
| 90 | + workspaceId: scopeType === "workspace" ? args.workspaceId : undefined, | |
| 91 | + targetActorId, | |
| 47 | 92 | clientId: args.clientId?.trim() || undefined, |
| 48 | − toolPathPattern: args.toolPathPattern, | |
| 49 | − decision: args.decision, | |
| 93 | + resourceType: "tool_path", | |
| 94 | + resourcePattern, | |
| 95 | + matchType, | |
| 96 | + effect, | |
| 97 | + approvalMode, | |
| 50 | 98 | priority: args.priority ?? 100, |
| 51 | 99 | createdAt: now, |
| 52 | 100 | updatedAt: now, |
| @@ -67,12 +115,26 @@ export const upsertAccessPolicy = internalMutation({ · 14 unmodified lines | ||
| 67 | 115 | export const listAccessPolicies = internalQuery({ |
| 68 | 116 | args: { workspaceId: v.id("workspaces") }, |
| 69 | 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 | 124 | .query("accessPolicies") |
| 72 | 125 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId)) |
| 73 | 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 | 138 | .sort((a, b) => { |
| 77 | 139 | if (a.priority !== b.priority) { |
| 78 | 140 | return b.priority - a.priority; |
| @@ -2,7 +2,6 @@ import { v } from "convex/values"; · 1 unmodified line | ||
| 2 | 2 | import { internalMutation, internalQuery } from "../_generated/server"; |
| 3 | 3 | import { getTaskDoc, mapTaskEvent } from "../../src/database/readers"; |
| 4 | 4 | import { jsonObjectValidator } from "../../src/database/validators"; |
| 5 | −import { asRecord } from "../../src/lib/object"; | |
| 6 | 5 | |
| 7 | 6 | export const createTaskEvent = internalMutation({ |
| 8 | 7 | args: { |
| @@ -30,7 +29,7 @@ export const createTaskEvent = internalMutation({ · 21 unmodified lines | ||
| 30 | 29 | taskId: args.taskId, |
| 31 | 30 | eventName: args.eventName, |
| 32 | 31 | type: args.type, |
| 33 | − payload: asRecord(args.payload), | |
| 32 | + payload: args.payload, | |
| 34 | 33 | createdAt, |
| 35 | 34 | }); |
| 36 | 35 | |
| @@ -3,7 +3,6 @@ import { internalMutation, internalQuery } from "../_generated/server"; · 2 unmodified lines | ||
| 3 | 3 | import { mapTask } from "../../src/database/mappers"; |
| 4 | 4 | import { getTaskDoc } from "../../src/database/readers"; |
| 5 | 5 | import { completedTaskStatusValidator, jsonObjectValidator } from "../../src/database/validators"; |
| 6 | −import { asRecord } from "../../src/lib/object"; | |
| 7 | 6 | import { DEFAULT_TASK_TIMEOUT_MS } from "../../src/task/constants"; |
| 8 | 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 | 26 | const now = Date.now(); |
| 27 | + const metadata = args.metadata === undefined | |
| 28 | + ? {} | |
| 29 | + : args.metadata; | |
| 28 | 30 | await ctx.db.insert("tasks", { |
| 29 | 31 | taskId: args.id, |
| 30 | 32 | code: args.code, |
| @@ -34,7 +36,7 @@ export const createTask = internalMutation({ · 3 unmodified lines | ||
| 34 | 36 | clientId: args.clientId?.trim() || undefined, |
| 35 | 37 | status: "queued", |
| 36 | 38 | timeoutMs: args.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS, |
| 37 | − metadata: asRecord(args.metadata), | |
| 39 | + metadata, | |
| 38 | 40 | createdAt: now, |
| 39 | 41 | updatedAt: now, |
| 40 | 42 | }); |
| @@ -6,12 +6,13 @@ import { · 5 unmodified lines | ||
| 6 | 6 | normalizeSourceAuthFingerprint, |
| 7 | 7 | } from "../../src/database/mappers"; |
| 8 | 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 | 11 | export const upsertToolSource = internalMutation({ |
| 12 | 12 | args: { |
| 13 | 13 | id: v.optional(v.string()), |
| 14 | 14 | workspaceId: v.id("workspaces"), |
| 15 | + ownerScopeType: v.optional(ownerScopeTypeValidator), | |
| 15 | 16 | name: v.string(), |
| 16 | 17 | type: toolSourceTypeValidator, |
| 17 | 18 | config: jsonObjectValidator, |
| @@ -20,6 +21,14 @@ export const upsertToolSource = internalMutation({ · 2 unmodified lines | ||
| 20 | 21 | handler: async (ctx, args) => { |
| 21 | 22 | const now = Date.now(); |
| 22 | 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 | 32 | const configResult = normalizeToolSourceConfig(args.type, args.config); |
| 24 | 33 | if (configResult.isErr()) { |
| 25 | 34 | throw new Error(configResult.error.message); |
| @@ -32,19 +41,28 @@ export const upsertToolSource = internalMutation({ · 6 unmodified lines | ||
| 32 | 41 | .query("toolSources") |
| 33 | 42 | .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) |
| 34 | 43 | .unique(), |
| 35 | − ctx.db | |
| 36 | − .query("toolSources") | |
| 37 | − .withIndex("by_workspace_name", (q) => q.eq("workspaceId", args.workspaceId).eq("name", args.name)) | |
| 38 | − .unique(), | |
| 44 | + ownerScopeType === "workspace" | |
| 45 | + ? ctx.db | |
| 46 | + .query("toolSources") | |
| 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 | 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 | 61 | if (existing) { |
| 46 | 62 | await ctx.db.patch(existing._id, { |
| 47 | − workspaceId: args.workspaceId, | |
| 63 | + ownerScopeType, | |
| 64 | + organizationId, | |
| 65 | + workspaceId: ownerWorkspaceId, | |
| 48 | 66 | name: args.name, |
| 49 | 67 | type: args.type, |
| 50 | 68 | config, |
| @@ -56,7 +74,9 @@ export const upsertToolSource = internalMutation({ · 5 unmodified lines | ||
| 56 | 74 | } else { |
| 57 | 75 | await ctx.db.insert("toolSources", { |
| 58 | 76 | sourceId, |
| 59 | − workspaceId: args.workspaceId, | |
| 77 | + ownerScopeType, | |
| 78 | + organizationId, | |
| 79 | + workspaceId: ownerWorkspaceId, | |
| 60 | 80 | name: args.name, |
| 61 | 81 | type: args.type, |
| 62 | 82 | config, |
| @@ -82,11 +102,30 @@ export const upsertToolSource = internalMutation({ · 19 unmodified lines | ||
| 82 | 102 | export const listToolSources = internalQuery({ |
| 83 | 103 | args: { workspaceId: v.id("workspaces") }, |
| 84 | 104 | handler: async (ctx, args) => { |
| 85 | − const docs = await ctx.db | |
| 86 | − .query("toolSources") | |
| 87 | − .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 88 | − .order("desc") | |
| 89 | − .collect(); | |
| 105 | + const workspace = await ctx.db.get(args.workspaceId); | |
| 106 | + if (!workspace) { | |
| 107 | + return []; | |
| 108 | + } | |
| 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 | 129 | return docs.map(mapSource); |
| 91 | 130 | }, |
| 92 | 131 | }); |
| @@ -94,20 +133,29 @@ export const listToolSources = internalQuery({ · 1 unmodified line | ||
| 94 | 133 | export const deleteToolSource = internalMutation({ |
| 95 | 134 | args: { workspaceId: v.id("workspaces"), sourceId: v.string() }, |
| 96 | 135 | handler: async (ctx, args) => { |
| 136 | + const workspace = await ctx.db.get(args.workspaceId); | |
| 137 | + if (!workspace) { | |
| 138 | + return false; | |
| 139 | + } | |
| 140 | + | |
| 97 | 141 | const doc = await ctx.db |
| 98 | 142 | .query("toolSources") |
| 99 | 143 | .withIndex("by_source_id", (q) => q.eq("sourceId", args.sourceId)) |
| 100 | 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 | 151 | return false; |
| 104 | 152 | } |
| 105 | 153 | |
| 106 | 154 | const sourceKey = `source:${args.sourceId}`; |
| 107 | 155 | const bindings = await ctx.db |
| 108 | 156 | .query("sourceCredentials") |
| 109 | − .withIndex("by_workspace_source_scope_key", (q) => | |
| 110 | − q.eq("workspaceId", args.workspaceId).eq("sourceKey", sourceKey), | |
| 157 | + .withIndex("by_organization_source", (q) => | |
| 158 | + q.eq("organizationId", workspace.organizationId).eq("sourceKey", sourceKey), | |
| 111 | 159 | ) |
| 112 | 160 | .collect(); |
| 113 | 161 | |
| @@ -127,9 +127,11 @@ export const cleanupTaskEmptyStringSentinels = migrations.define({ · 126 unmodified lines | ||
| 127 | 127 | export const cleanupAccessPolicyEmptyStringSentinels = migrations.define({ |
| 128 | 128 | table: "accessPolicies", |
| 129 | 129 | migrateOne: async (_ctx, policy) => { |
| 130 | + const policyRecord = policy as Record<string, unknown>; | |
| 130 | 131 | const patch: Record<string, undefined> = {}; |
| 131 | − if (policy.actorId === "") patch.actorId = undefined; | |
| 132 | − if (policy.clientId === "") patch.clientId = undefined; | |
| 132 | + if (Reflect.get(policyRecord, "actorId") === "") patch["actorId"] = undefined; | |
| 133 | + if (Reflect.get(policyRecord, "targetActorId") === "") patch["targetActorId"] = undefined; | |
| 134 | + if (policy.clientId === "") patch["clientId"] = undefined; | |
| 133 | 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 | 56 | v.literal("denied"), |
| 57 | 57 | ); |
| 58 | 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 | 65 | const credentialScope = v.union(v.literal("workspace"), v.literal("actor")); |
| 61 | 66 | const credentialProvider = v.union( |
| 62 | 67 | v.literal("local-convex"), |
| @@ -319,36 +324,46 @@ export default defineSchema({ · 256 unmodified lines | ||
| 319 | 324 | }) |
| 320 | 325 | .index("by_task_sequence", ["taskId", "sequence"]), |
| 321 | 326 | |
| 322 | − // Workspace access policy rules used by the approval / tool firewall. | |
| 323 | − // `toolPathPattern` is matched against tool paths and combined with actor/client selectors. | |
| 324 | − // Higher `priority` wins when multiple policies match. | |
| 327 | + // Workspace + organization access policy rules used by the approval / tool firewall. | |
| 328 | + // Rules can target specific actors and clients, and can match tool paths either exactly | |
| 329 | + // or with a glob pattern. | |
| 325 | 330 | accessPolicies: defineTable({ |
| 326 | 331 | policyId: v.string(), // domain ID: policy_<uuid> |
| 327 | − workspaceId: v.id("workspaces"), | |
| 328 | − actorId: v.optional(v.string()), // account._id or anon_<uuid> | |
| 332 | + scopeType: policyScopeType, | |
| 333 | + organizationId: v.id("organizations"), | |
| 334 | + workspaceId: v.optional(v.id("workspaces")), | |
| 335 | + targetActorId: v.optional(v.string()), // account._id or anon_<uuid> | |
| 329 | 336 | clientId: v.optional(v.string()), // client label: "web", "mcp", etc. |
| 330 | − toolPathPattern: v.string(), | |
| 331 | − decision: policyDecision, | |
| 337 | + resourceType: policyResourceType, | |
| 338 | + resourcePattern: v.string(), | |
| 339 | + matchType: policyMatchType, | |
| 340 | + effect: policyEffect, | |
| 341 | + approvalMode: policyApprovalMode, | |
| 332 | 342 | priority: v.number(), |
| 333 | 343 | createdAt: v.number(), |
| 334 | 344 | updatedAt: v.number(), |
| 335 | 345 | }) |
| 336 | 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 | 350 | // Stored credentials for tool sources. |
| 340 | 351 | // |
| 341 | 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 | 356 | // for UI/API operations that need an id before the connection id is known. |
| 344 | 357 | // |
| 345 | 358 | // Primary access patterns: |
| 346 | − // - Resolve by (workspaceId, sourceKey, scopeKey). | |
| 347 | − // - List all credentials in workspace by createdAt. | |
| 359 | + // - Resolve by (workspaceId|organizationId, sourceKey, scopeKey). | |
| 360 | + // - List effective credentials for a workspace by createdAt. | |
| 348 | 361 | sourceCredentials: defineTable({ |
| 349 | 362 | bindingId: v.string(), // domain ID: bind_<uuid> |
| 350 | 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 | 367 | sourceKey: v.string(), |
| 353 | 368 | scope: credentialScope, |
| 354 | 369 | scopeKey: v.string(), // "workspace" or actor:<actorId> |
| @@ -361,21 +376,30 @@ export default defineSchema({ · 6 unmodified lines | ||
| 361 | 376 | updatedAt: v.number(), |
| 362 | 377 | }) |
| 363 | 378 | .index("by_workspace_created", ["workspaceId", "createdAt"]) |
| 379 | + .index("by_organization_created", ["organizationId", "createdAt"]) | |
| 380 | + .index("by_organization_owner_created", ["organizationId", "ownerScopeType", "createdAt"]) | |
| 364 | 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 | 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 | 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 | 391 | // `specHash` enables cache invalidation when the definition changes. |
| 370 | 392 | // `authFingerprint` is used to determine whether cached tool materialization is still valid. |
| 371 | 393 | // |
| 372 | 394 | // Primary access patterns: |
| 373 | 395 | // - Resolve by domain source id. |
| 374 | − // - List sources by workspace, sorted by updatedAt. | |
| 375 | − // - Enforce name uniqueness per workspace. | |
| 396 | + // - List effective sources by workspace, sorted by updatedAt. | |
| 397 | + // - Enforce name uniqueness per owner scope. | |
| 376 | 398 | toolSources: defineTable({ |
| 377 | 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 | 403 | name: v.string(), |
| 380 | 404 | type: toolSourceType, |
| 381 | 405 | config: jsonObject, |
| @@ -387,7 +411,11 @@ export default defineSchema({ · 5 unmodified lines | ||
| 387 | 411 | }) |
| 388 | 412 | .index("by_source_id", ["sourceId"]) |
| 389 | 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 | 420 | // Cached OpenAPI spec blobs stored in Convex storage. |
| 393 | 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 | 241 | export const getToolByPath = internalQuery({ |
| 180 | 242 | args: { |
| 181 | 243 | workspaceId: v.id("workspaces"), |
| @@ -2,11 +2,17 @@ import { v } from "convex/values"; · 1 unmodified line | ||
| 2 | 2 | import { internal } from "./_generated/api"; |
| 3 | 3 | import { mutation } from "./_generated/server"; |
| 4 | 4 | import { workspaceMutation, workspaceQuery } from "../../core/src/function-builders"; |
| 5 | +import { getOrganizationMembership, isAdminRole } from "../../core/src/identity"; | |
| 5 | 6 | import { |
| 6 | 7 | credentialProviderValidator, |
| 7 | 8 | credentialScopeValidator, |
| 8 | 9 | jsonObjectValidator, |
| 10 | + ownerScopeTypeValidator, | |
| 11 | + policyApprovalModeValidator, | |
| 9 | 12 | policyDecisionValidator, |
| 13 | + policyEffectValidator, | |
| 14 | + policyMatchTypeValidator, | |
| 15 | + policyScopeTypeValidator, | |
| 10 | 16 | toolSourceTypeValidator, |
| 11 | 17 | } from "../src/database/validators"; |
| 12 | 18 | |
| @@ -43,13 +49,24 @@ export const upsertAccessPolicy = workspaceMutation({ · 30 unmodified lines | ||
| 43 | 49 | requireAdmin: true, |
| 44 | 50 | args: { |
| 45 | 51 | id: v.optional(v.string()), |
| 52 | + scopeType: v.optional(policyScopeTypeValidator), | |
| 46 | 53 | actorId: v.optional(v.string()), |
| 47 | 54 | clientId: v.optional(v.string()), |
| 48 | 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 | 60 | priority: v.optional(v.number()), |
| 51 | 61 | }, |
| 52 | 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 | 70 | return await ctx.runMutation(internal.database.upsertAccessPolicy, { |
| 54 | 71 | ...args, |
| 55 | 72 | workspaceId: ctx.workspaceId, |
| @@ -70,6 +87,7 @@ export const upsertCredential = workspaceMutation({ · 14 unmodified lines | ||
| 70 | 87 | requireAdmin: true, |
| 71 | 88 | args: { |
| 72 | 89 | id: v.optional(v.string()), |
| 90 | + ownerScopeType: v.optional(ownerScopeTypeValidator), | |
| 73 | 91 | sourceKey: v.string(), |
| 74 | 92 | scope: credentialScopeValidator, |
| 75 | 93 | actorId: v.optional(v.string()), |
| @@ -78,6 +96,13 @@ export const upsertCredential = workspaceMutation({ · 2 unmodified lines | ||
| 78 | 96 | overridesJson: v.optional(jsonObjectValidator), |
| 79 | 97 | }, |
| 80 | 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 | 106 | return await ctx.runMutation(internal.database.upsertCredential, { |
| 82 | 107 | ...args, |
| 83 | 108 | workspaceId: ctx.workspaceId, |
| @@ -122,12 +147,20 @@ export const upsertToolSource = workspaceMutation({ · 38 unmodified lines | ||
| 122 | 147 | requireAdmin: true, |
| 123 | 148 | args: { |
| 124 | 149 | id: v.optional(v.string()), |
| 150 | + ownerScopeType: v.optional(ownerScopeTypeValidator), | |
| 125 | 151 | name: v.string(), |
| 126 | 152 | type: toolSourceTypeValidator, |
| 127 | 153 | config: jsonObjectValidator, |
| 128 | 154 | enabled: v.optional(v.boolean()), |
| 129 | 155 | }, |
| 130 | 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 | 164 | return await ctx.runMutation(internal.database.upsertToolSource, { |
| 132 | 165 | ...args, |
| 133 | 166 | workspaceId: ctx.workspaceId, |
| @@ -150,6 +183,23 @@ export const deleteToolSource = workspaceMutation({ · 16 unmodified lines | ||
| 150 | 183 | sourceId: v.string(), |
| 151 | 184 | }, |
| 152 | 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 | 203 | return await ctx.runMutation(internal.database.deleteToolSource, { |
| 154 | 204 | ...args, |
| 155 | 205 | workspaceId: ctx.workspaceId, |
| @@ -135,6 +135,30 @@ async function deleteOrganizationData( · 134 unmodified lines | ||
| 135 | 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 | 162 | const workspaces = await ctx.db |
| 139 | 163 | .query("workspaces") |
| 140 | 164 | .withIndex("by_organization_created", (q) => q.eq("organizationId", organizationId)) |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { components } from "../../convex/_generated/api"; |
| 2 | +import { z } from "zod"; | |
| 2 | 3 | import type { RunQueryCtx } from "./types"; |
| 3 | 4 | |
| 4 | 5 | type WorkosProfile = { |
| @@ -8,17 +9,20 @@ type WorkosProfile = { · 3 unmodified lines | ||
| 8 | 9 | profilePictureUrl?: string | null; |
| 9 | 10 | }; |
| 10 | 11 | |
| 12 | +const nonEmptyTrimmedStringSchema = z.string().transform((value) => value.trim()).refine((value) => value.length > 0); | |
| 13 | + | |
| 11 | 14 | function deriveFallbackUserLabel(workosUserId: string): string { |
| 12 | 15 | return `User ${workosUserId.slice(-6)}`; |
| 13 | 16 | } |
| 14 | 17 | |
| 15 | 18 | function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { |
| 16 | 19 | for (const key of keys) { |
| 17 | − const value = identity[key]; | |
| 18 | − if (typeof value === "string" && value.trim().length > 0) { | |
| 19 | − return value.trim(); | |
| 20 | + const parsedValue = nonEmptyTrimmedStringSchema.safeParse(identity[key]); | |
| 21 | + if (parsedValue.success) { | |
| 22 | + return parsedValue.data; | |
| 20 | 23 | } |
| 21 | 24 | } |
| 25 | + | |
| 22 | 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 | 3 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| 3 | 4 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 5 | +import { | |
| 6 | + createWorkosClient, | |
| 7 | + extractWorkosVaultObjectId, | |
| 8 | + withWorkosVaultRetryResult, | |
| 9 | +} from "../../../core/src/credentials/workos-vault"; | |
| 4 | 10 | import { |
| 5 | 11 | assertMatchesCanonicalActorId, |
| 6 | 12 | canonicalActorIdForWorkspaceAccess, |
| 7 | 13 | } from "../auth/actor_identity"; |
| 8 | −import { asRecord } from "../lib/object"; | |
| 9 | 14 | |
| 10 | 15 | type Internal = typeof import("../../convex/_generated/api").internal; |
| 11 | 16 | |
| 12 | 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 | 41 | function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string { |
| 15 | 42 | if (scope !== "actor") return ""; |
| 16 | 43 | if (typeof actorId !== "string") return ""; |
| @@ -28,34 +55,40 @@ function configuredSecretBackend(): SecretBackend { · 11 unmodified lines | ||
| 28 | 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 | 58 | function extractHeaderOverrides(secretJson: Record<string, unknown>): { |
| 40 | 59 | cleanSecret: Record<string, unknown>; |
| 41 | 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 | 65 | const headers: Record<string, string> = {}; |
| 45 | 66 | for (const [name, value] of Object.entries(rawHeaders)) { |
| 46 | − if (!name) continue; | |
| 67 | + const headerName = name.trim(); | |
| 68 | + if (!headerName) continue; | |
| 47 | 69 | const text = typeof value === "string" ? value.trim() : String(value ?? "").trim(); |
| 48 | 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 | 75 | return { |
| 54 | − cleanSecret: asRecord(rest), | |
| 76 | + cleanSecret: toRecordValue(rest), | |
| 55 | 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 | 92 | function buildVaultObjectName(args: { |
| 60 | 93 | workspaceId: string; |
| 61 | 94 | sourceKey: string; |
| @@ -71,56 +104,6 @@ function buildVaultObjectName(args: { · 9 unmodified lines | ||
| 71 | 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 | 107 | async function upsertVaultObject(args: { |
| 125 | 108 | workspaceId: string; |
| 126 | 109 | sourceKey: string; |
| @@ -128,22 +111,36 @@ async function upsertVaultObject(args: { · 1 unmodified line | ||
| 128 | 111 | actorId: string; |
| 129 | 112 | existingObjectId: string | null; |
| 130 | 113 | payload: Record<string, unknown>; |
| 131 | −}): Promise<string> { | |
| 132 | − const workos = workosClient(); | |
| 114 | +}): Promise<Result<string, Error>> { | |
| 115 | + const workosResult = createWorkosClient(); | |
| 116 | + if (workosResult.isErr()) { | |
| 117 | + return Result.err(workosResult.error); | |
| 118 | + } | |
| 119 | + | |
| 120 | + const workos = workosResult.value; | |
| 133 | 121 | const value = JSON.stringify(args.payload); |
| 134 | 122 | |
| 135 | 123 | if (args.existingObjectId) { |
| 136 | 124 | const objectId: string = args.existingObjectId; |
| 137 | − const updated = await withVaultRetry(async () => { | |
| 125 | + const updatedResult = await withWorkosVaultRetryResult(async () => { | |
| 138 | 126 | return await workos.vault.updateObject({ |
| 139 | 127 | id: objectId, |
| 140 | 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 | 144 | return await workos.vault.createObject({ |
| 148 | 145 | name: buildVaultObjectName(args), |
| 149 | 146 | value, |
| @@ -151,9 +148,17 @@ async function upsertVaultObject(args: { · 1 unmodified line | ||
| 151 | 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 | 164 | export async function upsertCredentialHandler( |
| @@ -163,6 +168,7 @@ export async function upsertCredentialHandler( · 3 unmodified lines | ||
| 163 | 168 | id?: string; |
| 164 | 169 | workspaceId: Id<"workspaces">; |
| 165 | 170 | sessionId?: string; |
| 171 | + ownerScopeType?: "organization" | "workspace"; | |
| 166 | 172 | sourceKey: string; |
| 167 | 173 | scope: "workspace" | "actor"; |
| 168 | 174 | actorId?: string; |
| @@ -180,7 +186,8 @@ export async function upsertCredentialHandler( · 11 unmodified lines | ||
| 180 | 186 | } |
| 181 | 187 | |
| 182 | 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 | 191 | const { cleanSecret: submittedSecret, overridesJson } = extractHeaderOverrides(rawSubmittedSecret); |
| 185 | 192 | |
| 186 | 193 | const existingBinding = await ctx.runQuery(internal.database.resolveCredential, { |
| @@ -190,34 +197,36 @@ export async function upsertCredentialHandler( · 3 unmodified lines | ||
| 190 | 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 | 201 | workspaceId: args.workspaceId, |
| 195 | 202 | }); |
| 203 | + const allCredentials = parseListedCredentials(allCredentialsRaw); | |
| 196 | 204 | const requestedId = args.id?.trim(); |
| 197 | 205 | const existingConnection = requestedId |
| 198 | 206 | ? allCredentials.find((credential) => { |
| 199 | − const id = String(credential.id ?? ""); | |
| 200 | − const bindingId = String(credential.bindingId ?? ""); | |
| 207 | + const id = (credential.id ?? "").trim(); | |
| 208 | + const bindingId = (credential.bindingId ?? "").trim(); | |
| 201 | 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 | 212 | if (args.scope === "actor") { |
| 204 | − return String(credential.actorId ?? "") === actorId; | |
| 213 | + return (credential.actorId ?? "").trim() === actorId; | |
| 205 | 214 | } |
| 206 | 215 | return true; |
| 207 | 216 | }) ?? allCredentials.find((credential) => { |
| 208 | − const id = String(credential.id ?? ""); | |
| 209 | − const bindingId = String(credential.bindingId ?? ""); | |
| 217 | + const id = (credential.id ?? "").trim(); | |
| 218 | + const bindingId = (credential.bindingId ?? "").trim(); | |
| 210 | 219 | return id === requestedId || bindingId === requestedId; |
| 211 | 220 | }) |
| 212 | 221 | : null; |
| 213 | − const connectionId = String(existingConnection?.id ?? requestedId ?? "").trim() || undefined; | |
| 222 | + const connectionId = (existingConnection?.id ?? requestedId ?? "").trim() || undefined; | |
| 214 | 223 | |
| 215 | 224 | const backend = configuredSecretBackend(); |
| 216 | 225 | |
| 217 | 226 | if (backend === "local-convex") { |
| 218 | 227 | const finalSecret = Object.keys(submittedSecret).length > 0 |
| 219 | 228 | ? submittedSecret |
| 220 | − : asRecord(existingConnection?.secretJson ?? existingBinding?.secretJson); | |
| 229 | + : toRecordValue(existingConnection?.secretJson ?? existingBinding?.secretJson); | |
| 221 | 230 | if (Object.keys(finalSecret).length === 0) { |
| 222 | 231 | throw new Error("Credential values are required"); |
| 223 | 232 | } |
| @@ -225,6 +234,7 @@ export async function upsertCredentialHandler( · 1 unmodified line | ||
| 225 | 234 | return await ctx.runMutation(internal.database.upsertCredential, { |
| 226 | 235 | id: connectionId, |
| 227 | 236 | workspaceId: args.workspaceId, |
| 237 | + ownerScopeType, | |
| 228 | 238 | sourceKey: args.sourceKey, |
| 229 | 239 | scope: args.scope, |
| 230 | 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 | 248 | if (submittedObjectId && /^gh[opu]_/.test(submittedObjectId)) { |
| 239 | 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( | |
| 243 | − asRecord(existingConnection?.secretJson ?? existingBinding?.secretJson), | |
| 252 | + const existingObjectId = extractWorkosVaultObjectId( | |
| 253 | + toRecordValue(existingConnection?.secretJson ?? existingBinding?.secretJson), | |
| 244 | 254 | ); |
| 245 | 255 | |
| 246 | 256 | let finalObjectId = submittedObjectId; |
| 247 | 257 | if (!finalObjectId && Object.keys(submittedSecret).length > 0) { |
| 248 | − finalObjectId = await upsertVaultObject({ | |
| 258 | + const upsertResult = await upsertVaultObject({ | |
| 249 | 259 | workspaceId: args.workspaceId, |
| 250 | 260 | sourceKey: args.sourceKey, |
| 251 | 261 | scope: args.scope, |
| @@ -253,6 +263,10 @@ export async function upsertCredentialHandler( · 1 unmodified line | ||
| 253 | 263 | existingObjectId, |
| 254 | 264 | payload: submittedSecret, |
| 255 | 265 | }); |
| 266 | + if (upsertResult.isErr()) { | |
| 267 | + throw upsertResult.error; | |
| 268 | + } | |
| 269 | + finalObjectId = upsertResult.value; | |
| 256 | 270 | } |
| 257 | 271 | |
| 258 | 272 | if (!finalObjectId && existingObjectId) { |
| @@ -266,6 +280,7 @@ export async function upsertCredentialHandler( · 7 unmodified lines | ||
| 266 | 280 | return await ctx.runMutation(internal.database.upsertCredential, { |
| 267 | 281 | id: connectionId, |
| 268 | 282 | workspaceId: args.workspaceId, |
| 283 | + ownerScopeType, | |
| 269 | 284 | sourceKey: args.sourceKey, |
| 270 | 285 | scope: args.scope, |
| 271 | 286 | ...(args.scope === "actor" ? { actorId } : {}), |
| @@ -1,13 +1,20 @@ | ||
| 1 | 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 | 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 | 12 | function stableStringify(value: unknown): string { |
| 6 | 13 | if (Array.isArray(value)) { |
| 7 | 14 | return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; |
| 8 | 15 | } |
| 9 | − if (value && typeof value === "object") { | |
| 10 | − const record = asRecord(value); | |
| 16 | + if (isRecord(value)) { | |
| 17 | + const record = value; | |
| 11 | 18 | const keys = Object.keys(record).sort(); |
| 12 | 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 | 24 | export function normalizeSourceAuthFingerprint(value: unknown): string { |
| 18 | − const auth = asRecord(value); | |
| 19 | − const type = typeof auth.type === "string" ? auth.type.trim() : "none"; | |
| 25 | + const parsedAuth = sourceAuthSchema.safeParse(value); | |
| 26 | + const auth = parsedAuth.success ? parsedAuth.data : {}; | |
| 27 | + const type = (auth.type ?? "none").trim(); | |
| 20 | 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 | 30 | return stableStringify({ |
| 23 | 31 | type: type || "none", |
| 24 | 32 | mode, |
| @@ -43,13 +51,14 @@ export function computeSourceSpecHash(type: "mcp" | "openapi" | "graphql", confi · 18 unmodified lines | ||
| 43 | 51 | } |
| 44 | 52 | |
| 45 | 53 | export function mapTask(doc: Doc<"tasks">) { |
| 54 | + const metadata = isRecord(doc.metadata) ? doc.metadata : {}; | |
| 46 | 55 | return { |
| 47 | 56 | id: doc.taskId, |
| 48 | 57 | code: doc.code, |
| 49 | 58 | runtimeId: doc.runtimeId, |
| 50 | 59 | status: doc.status, |
| 51 | 60 | timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TASK_TIMEOUT_MS, |
| 52 | − metadata: asRecord(doc.metadata), | |
| 61 | + metadata, | |
| 53 | 62 | workspaceId: doc.workspaceId, |
| 54 | 63 | actorId: doc.actorId, |
| 55 | 64 | clientId: doc.clientId, |
| @@ -92,30 +101,64 @@ export function mapToolCall(doc: Doc<"toolCalls">) { · 36 unmodified lines | ||
| 92 | 101 | } |
| 93 | 102 | |
| 94 | 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 | 125 | return { |
| 96 | 126 | id: doc.policyId, |
| 127 | + scopeType, | |
| 128 | + organizationId: doc.organizationId, | |
| 97 | 129 | workspaceId: doc.workspaceId, |
| 98 | − actorId: doc.actorId, | |
| 130 | + targetActorId, | |
| 99 | 131 | clientId: doc.clientId, |
| 100 | − toolPathPattern: doc.toolPathPattern, | |
| 101 | − decision: doc.decision, | |
| 132 | + resourceType: doc.resourceType ?? "tool_path", | |
| 133 | + resourcePattern, | |
| 134 | + matchType: doc.matchType ?? "glob", | |
| 135 | + effect, | |
| 136 | + approvalMode, | |
| 102 | 137 | priority: doc.priority, |
| 103 | 138 | createdAt: doc.createdAt, |
| 104 | 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 | 147 | export function mapCredential(doc: Doc<"sourceCredentials">) { |
| 148 | + const secretJson = isRecord(doc.secretJson) ? doc.secretJson : {}; | |
| 149 | + const overridesJson = isRecord(doc.overridesJson) ? doc.overridesJson : {}; | |
| 109 | 150 | return { |
| 110 | 151 | id: doc.credentialId, |
| 111 | 152 | bindingId: doc.bindingId, |
| 153 | + ownerScopeType: doc.ownerScopeType ?? (doc.workspaceId ? "workspace" : "organization"), | |
| 154 | + organizationId: doc.organizationId, | |
| 112 | 155 | workspaceId: doc.workspaceId, |
| 113 | 156 | sourceKey: doc.sourceKey, |
| 114 | 157 | scope: doc.scope, |
| 115 | 158 | actorId: doc.actorId || undefined, |
| 116 | 159 | provider: doc.provider, |
| 117 | − secretJson: asRecord(doc.secretJson), | |
| 118 | − overridesJson: asRecord(doc.overridesJson), | |
| 160 | + secretJson, | |
| 161 | + overridesJson, | |
| 119 | 162 | boundAuthFingerprint: doc.boundAuthFingerprint, |
| 120 | 163 | createdAt: doc.createdAt, |
| 121 | 164 | updatedAt: doc.updatedAt, |
| @@ -123,12 +166,15 @@ export function mapCredential(doc: Doc<"sourceCredentials">) { · 1 unmodified line | ||
| 123 | 166 | } |
| 124 | 167 | |
| 125 | 168 | export function mapSource(doc: Doc<"toolSources">) { |
| 169 | + const config = isRecord(doc.config) ? doc.config : {}; | |
| 126 | 170 | return { |
| 127 | 171 | id: doc.sourceId, |
| 172 | + ownerScopeType: doc.ownerScopeType ?? (doc.workspaceId ? "workspace" : "organization"), | |
| 173 | + organizationId: doc.organizationId, | |
| 128 | 174 | workspaceId: doc.workspaceId, |
| 129 | 175 | name: doc.name, |
| 130 | 176 | type: doc.type, |
| 131 | − config: asRecord(doc.config), | |
| 177 | + config, | |
| 132 | 178 | specHash: doc.specHash, |
| 133 | 179 | authFingerprint: doc.authFingerprint, |
| 134 | 180 | enabled: Boolean(doc.enabled), |
| @@ -1,7 +1,11 @@ | ||
| 1 | 1 | import type { Doc } from "../../convex/_generated/dataModel.d.ts"; |
| 2 | 2 | import type { QueryCtx } from "../../convex/_generated/server"; |
| 3 | +import { z } from "zod"; | |
| 3 | 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 | 10 | export function mapAnonymousContext(doc: Doc<"anonymousSessions">) { |
| 7 | 11 | return { |
| @@ -69,9 +73,20 @@ export async function computeBoundAuthFingerprint( · 61 unmodified lines | ||
| 69 | 73 | .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) |
| 70 | 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 | 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 | 7 | OpenApiToolSourceConfig, |
| 8 | 8 | } from "../../../core/src/tool/source-types"; |
| 9 | 9 | import type { ToolApprovalMode } from "../../../core/src/types"; |
| 10 | −import { asRecord } from "../lib/object"; | |
| 11 | 10 | |
| 12 | 11 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; |
| 13 | 12 | |
| @@ -39,6 +38,8 @@ const overrideEntrySchema = z.object({ · 25 unmodified lines | ||
| 39 | 38 | }); |
| 40 | 39 | const unknownRecordSchema = z.record(z.unknown()); |
| 41 | 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 | 43 | const basicAuthSchema = z.object({ |
| 43 | 44 | mode: authModeSchema.optional(), |
| 44 | 45 | username: z.string().optional(), |
| @@ -55,20 +56,24 @@ const apiKeyAuthSchema = z.object({ · 10 unmodified lines | ||
| 55 | 56 | }); |
| 56 | 57 | |
| 57 | 58 | function optionalTrimmedString(value: unknown): string | undefined { |
| 58 | − if (typeof value !== "string") return undefined; | |
| 59 | − const trimmed = value.trim(); | |
| 60 | − return trimmed.length > 0 ? trimmed : undefined; | |
| 59 | + const parsed = trimmedStringSchema.safeParse(value); | |
| 60 | + if (!parsed.success || parsed.data.length === 0) { | |
| 61 | + return undefined; | |
| 62 | + } | |
| 63 | + | |
| 64 | + return parsed.data; | |
| 61 | 65 | } |
| 62 | 66 | |
| 63 | 67 | function requiredTrimmedString( |
| 64 | 68 | value: unknown, |
| 65 | 69 | fieldName: string, |
| 66 | 70 | ): Result<string, Error> { |
| 67 | − const trimmed = optionalTrimmedString(value); | |
| 68 | − if (!trimmed) { | |
| 71 | + const parsed = nonEmptyTrimmedStringSchema.safeParse(value); | |
| 72 | + if (!parsed.success) { | |
| 69 | 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 | 79 | function normalizeStringMap( |
| @@ -151,7 +156,12 @@ function normalizeOverrides( · 76 unmodified lines | ||
| 151 | 156 | function normalizeAuth(value: unknown): Result<OpenApiAuth | undefined, Error> { |
| 152 | 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 | 165 | const authType = optionalTrimmedString(auth.type); |
| 156 | 166 | if (!authType) { |
| 157 | 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 | 227 | function normalizeSpec( |
| 218 | 228 | value: unknown, |
| 219 | 229 | ): Result<string | Record<string, unknown>, Error> { |
| 220 | − const stringSpec = z.string().safeParse(value); | |
| 230 | + const stringSpec = nonEmptyTrimmedStringSchema.safeParse(value); | |
| 221 | 231 | if (stringSpec.success) { |
| 222 | − const trimmed = stringSpec.data.trim(); | |
| 223 | − if (!trimmed) { | |
| 224 | − return Result.err(new Error("Tool source spec is required")); | |
| 225 | − } | |
| 226 | − return Result.ok(trimmed); | |
| 232 | + return Result.ok(stringSpec.data); | |
| 227 | 233 | } |
| 228 | 234 | |
| 229 | 235 | const specObjectResult = unknownRecordSchema.safeParse(value); |
| @@ -18,6 +18,11 @@ export const terminalToolCallStatusValidator = v.union( · 17 unmodified lines | ||
| 18 | 18 | ); |
| 19 | 19 | |
| 20 | 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 | 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> { | |
| 2 | − if (value && typeof value === "object" && !Array.isArray(value)) { | |
| 3 | − return value as Record<string, unknown>; | |
| 4 | − } | |
| 5 | − return {}; | |
| 1 | +import { z } from "zod"; | |
| 2 | + | |
| 3 | +const recordSchema = z.record(z.unknown()); | |
| 4 | + | |
| 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 | − if (value && typeof value === "object") { | |
| 10 | − return value as Record<string, unknown>; | |
| 11 | − } | |
| 12 | − return { value }; | |
| 9 | +export function toRecord(value: unknown): Record<string, unknown> { | |
| 10 | + const parsed = recordSchema.safeParse(value); | |
| 11 | + return parsed.success ? parsed.data : {}; | |
| 13 | 12 | } |
| @@ -7,21 +7,55 @@ import type { · 6 unmodified lines | ||
| 7 | 7 | ToolDefinition, |
| 8 | 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 | 15 | const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); |
| 12 | 16 | const regex = new RegExp(`^${escaped}$`); |
| 13 | 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 | 29 | let score = 0; |
| 18 | − if (policy.actorId && actorId && policy.actorId === actorId) score += 4; | |
| 19 | − if (policy.clientId && clientId && policy.clientId === clientId) score += 2; | |
| 20 | − score += Math.max(1, policy.toolPathPattern.replace(/\*/g, "").length); | |
| 30 | + if (scopeType === "workspace" && policy.workspaceId === context.workspaceId) score += 16; | |
| 31 | + if (targetActorId && context.actorId && targetActorId === context.actorId) score += 64; | |
| 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 | 35 | score += policy.priority; |
| 22 | 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 | 59 | export function getDecisionForContext( |
| 26 | 60 | tool: ToolDefinition, |
| 27 | 61 | context: { workspaceId: string; actorId?: string; clientId?: string }, |
| @@ -34,17 +68,24 @@ export function getDecisionForContext( · 6 unmodified lines | ||
| 34 | 68 | const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow"; |
| 35 | 69 | const candidates = policies |
| 36 | 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 | 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 | 82 | .sort( |
| 42 | 83 | (a, b) => |
| 43 | − policySpecificity(b, context.actorId, context.clientId) | |
| 44 | − - policySpecificity(a, context.actorId, context.clientId), | |
| 84 | + policySpecificity(b, context) | |
| 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 | 91 | export function getToolDecision( |
| @@ -1,38 +1,21 @@ | ||
| 1 | 1 | "use node"; |
| 2 | 2 | |
| 3 | 3 | import { Result } from "better-result"; |
| 4 | −import { z } from "zod"; | |
| 5 | 4 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 6 | 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 | 11 | import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types"; |
| 9 | 12 | import { ToolCallControlError } from "../../../core/src/tool-call-control"; |
| 10 | −import { asPayload } from "../lib/object"; | |
| 11 | 13 | |
| 12 | −const bearerSecretSchema = z.object({ | |
| 13 | − token: z.coerce.string().optional(), | |
| 14 | −}); | |
| 15 | − | |
| 16 | −const apiKeySecretSchema = z.object({ | |
| 17 | − headerName: z.coerce.string().optional(), | |
| 18 | − value: z.coerce.string().optional(), | |
| 19 | − token: z.coerce.string().optional(), | |
| 20 | −}); | |
| 21 | − | |
| 22 | −const basicSecretSchema = z.object({ | |
| 23 | − username: z.coerce.string().optional(), | |
| 24 | − password: z.coerce.string().optional(), | |
| 25 | −}); | |
| 26 | − | |
| 27 | −const credentialOverrideHeadersSchema = z.object({ | |
| 28 | − headers: z.record(z.coerce.string()).optional(), | |
| 29 | −}); | |
| 30 | − | |
| 31 | −export async function resolveCredentialHeaders( | |
| 32 | − ctx: ActionCtx, | |
| 14 | +export async function resolveCredentialHeadersResult( | |
| 15 | + ctx: Pick<ActionCtx, "runQuery">, | |
| 33 | 16 | spec: ToolCredentialSpec, |
| 34 | 17 | task: TaskRecord, |
| 35 | −): Promise<ResolvedToolCredential | null> { | |
| 18 | +): Promise<Result<ResolvedToolCredential | null, Error>> { | |
| 36 | 19 | const record = await ctx.runQuery(internal.database.resolveCredential, { |
| 37 | 20 | workspaceId: task.workspaceId, |
| 38 | 21 | sourceKey: spec.sourceKey, |
| @@ -40,53 +23,55 @@ export async function resolveCredentialHeaders( · 1 unmodified line | ||
| 40 | 23 | actorId: task.actorId, |
| 41 | 24 | }); |
| 42 | 25 | |
| 43 | − const source = record | |
| 44 | − ? await resolveCredentialPayload(record) | |
| 45 | − : spec.staticSecretJson ?? null; | |
| 46 | − if (!source) { | |
| 47 | − return null; | |
| 26 | + const sourceResult = record | |
| 27 | + ? await resolveCredentialPayloadResult(record) | |
| 28 | + : Result.ok(spec.staticSecretJson ?? null); | |
| 29 | + if (sourceResult.isErr()) { | |
| 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> = {}; | |
| 52 | − if (spec.authType === "bearer") { | |
| 53 | − const parsedSecret = bearerSecretSchema.safeParse(sourcePayload); | |
| 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 | − } | |
| 35 | + const source = sourceResult.value; | |
| 36 | + if (!source) { | |
| 37 | + return Result.ok(null); | |
| 72 | 38 | } |
| 73 | 39 | |
| 74 | − const bindingOverrides = credentialOverrideHeadersSchema.safeParse(record?.overridesJson ?? {}); | |
| 75 | − const overrideHeaders = bindingOverrides.success ? (bindingOverrides.data.headers ?? {}) : {}; | |
| 40 | + const headers = buildCredentialAuthHeaders( | |
| 41 | + { | |
| 42 | + authType: spec.authType, | |
| 43 | + headerName: spec.headerName, | |
| 44 | + }, | |
| 45 | + source, | |
| 46 | + ); | |
| 47 | + | |
| 48 | + const overrideHeaders = readCredentialOverrideHeaders(record?.overridesJson); | |
| 76 | 49 | for (const [key, value] of Object.entries(overrideHeaders)) { |
| 77 | − if (!key) continue; | |
| 78 | 50 | headers[key] = value; |
| 79 | 51 | } |
| 80 | 52 | |
| 81 | 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 | 58 | sourceKey: spec.sourceKey, |
| 87 | 59 | mode: spec.mode, |
| 88 | 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 | 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 | 7 | import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths"; |
| 8 | 8 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types"; |
| 9 | 9 | import { parseSerializedTool, rehydrateTools } from "../../../core/src/tool/source-serialization"; |
| 10 | −import { asPayload } from "../lib/object"; | |
| 11 | 10 | import { getDecisionForContext, getToolDecision } from "./policy"; |
| 12 | −import { getReadyRegistryBuildId } from "./tool_registry_state"; | |
| 11 | +import { getReadyRegistryBuildIdResult } from "./tool_registry_state"; | |
| 13 | 12 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; |
| 14 | 13 | import { baseTools } from "./workspace_tools"; |
| 15 | 14 | |
| @@ -27,6 +26,24 @@ const registrySerializedToolEntrySchema = z.object({ · 11 unmodified lines | ||
| 27 | 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 | 47 | async function searchRegistryEntries( |
| 31 | 48 | ctx: ActionCtx, |
| 32 | 49 | args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number }, |
| @@ -66,9 +83,12 @@ export function getGraphqlDecision( · 33 unmodified lines | ||
| 66 | 83 | workspaceTools: Map<string, ToolDefinition> | undefined, |
| 67 | 84 | policies: AccessPolicyRecord[], |
| 68 | 85 | ): { decision: PolicyDecision; effectivePaths: string[] } { |
| 69 | − const sourceName = tool._graphqlSource!; | |
| 70 | − const payload = asPayload(input); | |
| 71 | − const queryString = typeof payload.query === "string" ? payload.query : ""; | |
| 86 | + const sourceName = tool._graphqlSource; | |
| 87 | + if (!sourceName) { | |
| 88 | + return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; | |
| 89 | + } | |
| 90 | + | |
| 91 | + const queryString = getGraphqlQueryFromInput(input); | |
| 72 | 92 | |
| 73 | 93 | if (!queryString.trim()) { |
| 74 | 94 | return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; |
| @@ -153,12 +173,16 @@ export async function resolveToolForCall( · 78 unmodified lines | ||
| 153 | 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 | 177 | workspaceId: task.workspaceId, |
| 158 | 178 | actorId: task.actorId, |
| 159 | 179 | clientId: task.clientId, |
| 160 | 180 | refreshOnStale: true, |
| 161 | 181 | }); |
| 182 | + if (buildIdResult.isErr()) { | |
| 183 | + return Result.err(buildIdResult.error); | |
| 184 | + } | |
| 185 | + const buildId = buildIdResult.value; | |
| 162 | 186 | |
| 163 | 187 | let resolvedToolPath = toolPath; |
| 164 | 188 | let entry = await getRegistryToolByPath(ctx, { |
| @@ -179,8 +203,12 @@ export async function resolveToolForCall( · 14 unmodified lines | ||
| 179 | 203 | if (hits.length > 0) { |
| 180 | 204 | // Prefer exact match on preferred path formatting, otherwise shortest canonical path. |
| 181 | 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]!; | |
| 183 | − resolvedToolPath = entry.path; | |
| 206 | + const shortest = [...hits] | |
| 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 | 217 | return Result.err(new Error(unknownToolErrorMessage(toolPath, suggestions))); |
| 190 | 218 | } |
| 191 | 219 | |
| 192 | − const serializedResult = Result.try(() => { | |
| 193 | − return JSON.parse(entry.serializedToolJson); | |
| 194 | − }); | |
| 195 | − if (serializedResult.isErr()) { | |
| 220 | + let parsedSerialized: unknown; | |
| 221 | + try { | |
| 222 | + parsedSerialized = JSON.parse(entry.serializedToolJson); | |
| 223 | + } catch (error) { | |
| 224 | + const message = error instanceof Error ? error.message : String(error); | |
| 196 | 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 | 230 | if (serialized.isErr()) { |
| 202 | 231 | return Result.err( |
| 203 | 232 | new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serialized.error.message}`), |
| @@ -18,14 +18,15 @@ import { · 17 unmodified lines | ||
| 18 | 18 | decodeToolCallControlSignal, |
| 19 | 19 | ToolCallControlError, |
| 20 | 20 | } from "../../../core/src/tool-call-control"; |
| 21 | −import { asPayload } from "../lib/object"; | |
| 22 | 21 | import { getToolDecision, getDecisionForContext } from "./policy"; |
| 23 | 22 | import { baseTools } from "./workspace_tools"; |
| 24 | 23 | import { publishTaskEvent } from "./events"; |
| 25 | 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 | 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 | 31 | function createApprovalId(): string { |
| 31 | 32 | return `approval_${crypto.randomUUID()}`; |
| @@ -41,18 +42,6 @@ type RegistryToolEntry = { · 9 unmodified lines | ||
| 41 | 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 | 45 | const registryNamespaceSchema = z.object({ |
| 57 | 46 | namespace: z.string(), |
| 58 | 47 | toolCount: z.number(), |
| @@ -85,6 +74,15 @@ const discoverInputSchema = z.object({ · 26 unmodified lines | ||
| 85 | 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 | 86 | async function upsertRequestedToolCall( |
| 89 | 87 | ctx: ActionCtx, |
| 90 | 88 | args: { taskId: string; callId: string; workspaceId: TaskRecord["workspaceId"]; toolPath: string }, |
| @@ -98,8 +96,7 @@ async function listWorkspaceAccessPolicies( · 7 unmodified lines | ||
| 98 | 96 | workspaceId: TaskRecord["workspaceId"], |
| 99 | 97 | ): Promise<AccessPolicyRecord[]> { |
| 100 | 98 | const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId }); |
| 101 | − const parsed = z.array(accessPolicyRecordSchema).safeParse(policies); | |
| 102 | − return parsed.success ? parsed.data : []; | |
| 99 | + return policies as AccessPolicyRecord[]; | |
| 103 | 100 | } |
| 104 | 101 | |
| 105 | 102 | async function listRegistryNamespaces( |
| @@ -183,16 +180,20 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 77 unmodified lines | ||
| 183 | 180 | |
| 184 | 181 | // Fast system tools are handled server-side from the registry. |
| 185 | 182 | if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") { |
| 186 | − const buildId = await getReadyRegistryBuildId(ctx, { | |
| 183 | + const buildIdResult = await getReadyRegistryBuildIdResult(ctx, { | |
| 187 | 184 | workspaceId: task.workspaceId, |
| 188 | 185 | actorId: task.actorId, |
| 189 | 186 | clientId: task.clientId, |
| 190 | 187 | refreshOnStale: true, |
| 191 | 188 | }); |
| 189 | + if (buildIdResult.isErr()) { | |
| 190 | + throw buildIdResult.error; | |
| 191 | + } | |
| 192 | + const buildId = buildIdResult.value; | |
| 192 | 193 | |
| 193 | 194 | const payload = typeof input === "string" |
| 194 | 195 | ? { query: input } |
| 195 | − : asPayload(input); | |
| 196 | + : toInputPayload(input); | |
| 196 | 197 | const isAllowed = (path: string, approval: ToolDefinition["approval"]) => { |
| 197 | 198 | const policyProbeTool: ToolDefinition = { |
| 198 | 199 | path, |
| @@ -339,7 +340,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 140 unmodified lines | ||
| 339 | 340 | |
| 340 | 341 | let credential: ResolvedToolCredential | undefined; |
| 341 | 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 | 349 | if (!resolved) { |
| 344 | 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 | 408 | taskId: task.id, |
| 403 | 409 | callId, |
| 404 | 410 | toolPath: approval.toolPath, |
| 405 | − input: asPayload(approval.input), | |
| 411 | + input: toInputPayload(approval.input), | |
| 406 | 412 | createdAt: approval.createdAt, |
| 407 | 413 | }); |
| 408 | 414 | } |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | "use node"; |
| 2 | 2 | |
| 3 | +import { Result } from "better-result"; | |
| 3 | 4 | import { z } from "zod"; |
| 4 | 5 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 5 | 6 | import type { Id } from "../../convex/_generated/dataModel.d.ts"; |
| @@ -21,12 +22,6 @@ type RegistryState = { · 15 unmodified lines | ||
| 21 | 22 | readyBuildId?: string; |
| 22 | 23 | } | null; |
| 23 | 24 | |
| 24 | −type ToolSourceState = { | |
| 25 | − id: string; | |
| 26 | − updatedAt: number; | |
| 27 | − enabled: boolean; | |
| 28 | −}; | |
| 29 | − | |
| 30 | 25 | const registryStateSchema = z.object({ |
| 31 | 26 | signature: z.string(), |
| 32 | 27 | readyBuildId: z.string().optional(), |
| @@ -34,9 +29,29 @@ const registryStateSchema = z.object({ · 1 unmodified line | ||
| 34 | 29 | |
| 35 | 30 | const toolSourceStateSchema = z.object({ |
| 36 | 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 | 38 | updatedAt: z.number(), |
| 38 | 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 | 56 | function toRegistryState(value: unknown): RegistryState { |
| 42 | 57 | const parsed = registryStateSchema.safeParse(value); |
| @@ -51,23 +66,12 @@ function toRegistryState(value: unknown): RegistryState { · 8 unmodified lines | ||
| 51 | 66 | } |
| 52 | 67 | |
| 53 | 68 | function toToolSourceStateList(value: unknown): ToolSourceState[] { |
| 54 | − if (!Array.isArray(value)) { | |
| 69 | + const parsed = toolSourceStateListSchema.safeParse(value); | |
| 70 | + if (!parsed.success) { | |
| 55 | 71 | return []; |
| 56 | 72 | } |
| 57 | 73 | |
| 58 | − const normalized: ToolSourceState[] = []; | |
| 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; | |
| 74 | + return parsed.data; | |
| 71 | 75 | } |
| 72 | 76 | |
| 73 | 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 | 98 | ctx: Pick<ActionCtx, "runQuery" | "runAction">, |
| 95 | 99 | args: { |
| 96 | 100 | workspaceId: Id<"workspaces">; |
| @@ -98,10 +102,10 @@ export async function getReadyRegistryBuildId( · 1 unmodified line | ||
| 98 | 102 | clientId?: string; |
| 99 | 103 | refreshOnStale?: boolean; |
| 100 | 104 | }, |
| 101 | −): Promise<string> { | |
| 105 | +): Promise<Result<string, Error>> { | |
| 102 | 106 | const initial = await readRegistryState(ctx, args.workspaceId); |
| 103 | 107 | if (initial.isReady && initial.buildId) { |
| 104 | − return initial.buildId; | |
| 108 | + return Result.ok(initial.buildId); | |
| 105 | 109 | } |
| 106 | 110 | |
| 107 | 111 | if (args.refreshOnStale) { |
| @@ -113,11 +117,28 @@ export async function getReadyRegistryBuildId( · 5 unmodified lines | ||
| 113 | 117 | |
| 114 | 118 | const refreshed = await readRegistryState(ctx, args.workspaceId); |
| 115 | 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( | |
| 121 | − "Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild.", | |
| 124 | + return Result.err( | |
| 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 | 11 | prepareOpenApiSpec, |
| 12 | 12 | type CompiledToolSourceArtifact, |
| 13 | 13 | } from "../../../core/src/tool-sources"; |
| 14 | −import { resolveCredentialPayload } from "../../../core/src/credential-providers"; | |
| 15 | −import { buildStaticAuthHeaders } from "../../../core/src/tool/source-auth"; | |
| 14 | +import { resolveCredentialPayloadResult } from "../../../core/src/credential-providers"; | |
| 15 | +import { | |
| 16 | + buildCredentialAuthHeaders, | |
| 17 | + buildStaticAuthHeaders, | |
| 18 | + readCredentialOverrideHeaders, | |
| 19 | +} from "../../../core/src/tool/source-auth"; | |
| 16 | 20 | import type { |
| 17 | 21 | ExternalToolSourceConfig, |
| 18 | 22 | GraphqlToolSourceConfig, |
| @@ -28,6 +32,7 @@ const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; · 9 unmodified lines | ||
| 28 | 32 | |
| 29 | 33 | /** Cache version - bump when tool snapshot/registry/type-hint semantics change. */ |
| 30 | 34 | const TOOL_SOURCE_CACHE_VERSION = "v25"; |
| 35 | +const OPENAPI_PREPARED_CACHE_VERSION = "openapi_v1"; | |
| 31 | 36 | |
| 32 | 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 | 60 | const preparedOpenApiSpecSchema = z.object({ |
| 69 | 61 | servers: z.array(z.string()), |
| 70 | 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 | 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 | 119 | .sort(); |
| 106 | 120 | return `${TOOL_SOURCE_CACHE_VERSION}|${workspaceId}|${parts.join(",")}`; |
| 107 | 121 | } |
| @@ -189,36 +203,23 @@ export function normalizeExternalToolSource(raw: { · 81 unmodified lines | ||
| 189 | 203 | return Result.ok(result); |
| 190 | 204 | } |
| 191 | 205 | |
| 192 | −function buildHeadersFromCredentialSecret( | |
| 193 | − auth: OpenApiAuth, | |
| 194 | − secret: Record<string, unknown>, | |
| 195 | −): Record<string, string> { | |
| 196 | − if (auth.type === "bearer") { | |
| 197 | − const parsedSecret = bearerSecretSchema.safeParse(secret); | |
| 198 | − const token = parsedSecret.success ? (parsedSecret.data.token ?? "").trim() : ""; | |
| 199 | − return token ? { authorization: `Bearer ${token}` } : {}; | |
| 206 | +function toCredentialHeaderSpec(auth: OpenApiAuth): | |
| 207 | + | { authType: "bearer" | "apiKey" | "basic"; headerName?: string } | |
| 208 | + | null { | |
| 209 | + if (auth.type === "none") { | |
| 210 | + return null; | |
| 200 | 211 | } |
| 201 | 212 | |
| 202 | 213 | if (auth.type === "apiKey") { |
| 203 | − const parsedSecret = apiKeySecretSchema.safeParse(secret); | |
| 204 | − const value = parsedSecret.success | |
| 205 | − ? (parsedSecret.data.value ?? parsedSecret.data.token ?? "").trim() | |
| 206 | − : ""; | |
| 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}` }; | |
| 214 | + return { | |
| 215 | + authType: "apiKey", | |
| 216 | + headerName: auth.header, | |
| 217 | + }; | |
| 219 | 218 | } |
| 220 | 219 | |
| 221 | − return {}; | |
| 220 | + return { | |
| 221 | + authType: auth.type, | |
| 222 | + }; | |
| 222 | 223 | } |
| 223 | 224 | |
| 224 | 225 | async function resolveMcpDiscoveryHeaders( |
| @@ -258,38 +259,41 @@ async function resolveMcpDiscoveryHeaders( · 33 unmodified lines | ||
| 258 | 259 | }; |
| 259 | 260 | } |
| 260 | 261 | |
| 261 | − try { | |
| 262 | − const secret = await resolveCredentialPayload(record); | |
| 263 | − if (!secret) { | |
| 264 | − return { | |
| 265 | − headers: {}, | |
| 266 | − warnings: [`Source '${source.name}': credential payload unavailable for MCP discovery`], | |
| 267 | − }; | |
| 268 | − } | |
| 262 | + const secretResult = await resolveCredentialPayloadResult(record); | |
| 263 | + if (secretResult.isErr()) { | |
| 264 | + return { | |
| 265 | + headers: {}, | |
| 266 | + warnings: [`Source '${source.name}': failed to resolve MCP credential for discovery: ${secretResult.error.message}`], | |
| 267 | + }; | |
| 268 | + } | |
| 269 | 269 | |
| 270 | − const headers = buildHeadersFromCredentialSecret(auth, secret); | |
| 271 | − const parsedOverrides = credentialOverrideHeadersSchema.safeParse(record.overridesJson ?? {}); | |
| 272 | − const overrideHeaders = parsedOverrides.success ? (parsedOverrides.data.headers ?? {}) : {}; | |
| 273 | − for (const [key, value] of Object.entries(overrideHeaders)) { | |
| 274 | − if (!key) continue; | |
| 275 | − headers[key] = value; | |
| 276 | − } | |
| 270 | + const secret = secretResult.value; | |
| 271 | + if (!secret) { | |
| 272 | + return { | |
| 273 | + headers: {}, | |
| 274 | + warnings: [`Source '${source.name}': credential payload unavailable for MCP discovery`], | |
| 275 | + }; | |
| 276 | + } | |
| 277 | 277 | |
| 278 | − if (Object.keys(headers).length === 0) { | |
| 279 | − return { | |
| 280 | − headers: {}, | |
| 281 | − warnings: [`Source '${source.name}': credential did not produce MCP auth headers for discovery`], | |
| 282 | − }; | |
| 283 | − } | |
| 278 | + const authSpec = toCredentialHeaderSpec(auth); | |
| 279 | + if (!authSpec) { | |
| 280 | + return { headers: {}, warnings: [] }; | |
| 281 | + } | |
| 284 | 282 | |
| 285 | − return { headers, warnings: [] }; | |
| 286 | − } catch (error) { | |
| 287 | − const message = error instanceof Error ? error.message : String(error); | |
| 283 | + const headers = buildCredentialAuthHeaders(authSpec, secret); | |
| 284 | + const overrideHeaders = readCredentialOverrideHeaders(record.overridesJson); | |
| 285 | + for (const [key, value] of Object.entries(overrideHeaders)) { | |
| 286 | + headers[key] = value; | |
| 287 | + } | |
| 288 | + | |
| 289 | + if (Object.keys(headers).length === 0) { | |
| 288 | 290 | return { |
| 289 | 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 | 299 | async function loadCachedOpenApiSpec( |
| @@ -308,7 +312,7 @@ async function loadCachedOpenApiSpec( · 12 unmodified lines | ||
| 308 | 312 | try { |
| 309 | 313 | const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, { |
| 310 | 314 | specUrl, |
| 311 | − version: TOOL_SOURCE_CACHE_VERSION, | |
| 315 | + version: OPENAPI_PREPARED_CACHE_VERSION, | |
| 312 | 316 | maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS, |
| 313 | 317 | }); |
| 314 | 318 | |
| @@ -316,9 +320,15 @@ async function loadCachedOpenApiSpec( · 1 unmodified line | ||
| 316 | 320 | const blob = await ctx.storage.get(entry.storageId); |
| 317 | 321 | if (blob) { |
| 318 | 322 | const json = await blob.text(); |
| 319 | − const parsedResult = Result.try(() => JSON.parse(json)); | |
| 320 | − if (parsedResult.isOk()) { | |
| 321 | − const prepared = toPreparedOpenApiSpec(parsedResult.value); | |
| 323 | + let parsedJson: unknown; | |
| 324 | + try { | |
| 325 | + parsedJson = JSON.parse(json); | |
| 326 | + } catch { | |
| 327 | + parsedJson = undefined; | |
| 328 | + } | |
| 329 | + | |
| 330 | + if (parsedJson !== undefined) { | |
| 331 | + const prepared = toPreparedOpenApiSpec(parsedJson); | |
| 322 | 332 | if (prepared && (!includeDts || getDtsStatus(prepared) !== "skipped")) { |
| 323 | 333 | return prepared; |
| 324 | 334 | } |
| @@ -338,7 +348,7 @@ async function loadCachedOpenApiSpec( · 13 unmodified lines | ||
| 338 | 348 | const storageId = await ctx.storage.store(blob); |
| 339 | 349 | await ctx.runMutation(internal.openApiSpecCache.putEntry, { |
| 340 | 350 | specUrl, |
| 341 | − version: TOOL_SOURCE_CACHE_VERSION, | |
| 351 | + version: OPENAPI_PREPARED_CACHE_VERSION, | |
| 342 | 352 | storageId, |
| 343 | 353 | sizeBytes: json.length, |
| 344 | 354 | }); |
| @@ -354,7 +364,7 @@ export async function loadSourceArtifact( · 9 unmodified lines | ||
| 354 | 364 | ctx: ActionCtx, |
| 355 | 365 | source: ExternalToolSourceConfig, |
| 356 | 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 | 368 | const includeDts = options.includeDts ?? true; |
| 359 | 369 | |
| 360 | 370 | if (source.type === "openapi" && typeof source.spec === "string") { |
| @@ -364,7 +374,12 @@ export async function loadSourceArtifact( · 3 unmodified lines | ||
| 364 | 374 | const warnings = (prepared.warnings ?? []).map( |
| 365 | 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 | 383 | } catch (error) { |
| 369 | 384 | const message = error instanceof Error ? error.message : String(error); |
| 370 | 385 | return { |
| @@ -30,7 +30,6 @@ import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_ · 29 unmodified lines | ||
| 30 | 30 | import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading"; |
| 31 | 31 | import { registrySignatureForWorkspace } from "./tool_registry_state"; |
| 32 | 32 | import { normalizeToolPathForLookup } from "./tool_paths"; |
| 33 | −import { asPayload } from "../lib/object"; | |
| 34 | 33 | |
| 35 | 34 | const baseTools = new Map<string, ToolDefinition>(); |
| 36 | 35 | |
| @@ -39,6 +38,33 @@ const adminAnnouncementInputSchema = z.object({ · 2 unmodified lines | ||
| 39 | 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 | 68 | async function listWorkspaceToolSources( |
| 43 | 69 | ctx: ActionCtx, |
| 44 | 70 | workspaceId: Id<"workspaces">, |
| @@ -56,17 +82,23 @@ async function listWorkspaceAccessPolicies( · 11 unmodified lines | ||
| 56 | 82 | } |
| 57 | 83 | |
| 58 | 84 | async function parseWorkspaceToolSnapshotFromBlob(blob: Blob): Promise<Result<WorkspaceToolSnapshot, Error>> { |
| 59 | − const textResult = await Result.tryPromise(async () => await blob.text()); | |
| 60 | − if (textResult.isErr()) { | |
| 61 | − return Result.err(new Error(`Failed to read cache blob: ${textResult.error.message}`)); | |
| 85 | + let text: string; | |
| 86 | + try { | |
| 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)); | |
| 65 | − if (jsonResult.isErr()) { | |
| 66 | − return Result.err(new Error(`Failed to parse cache JSON: ${jsonResult.error.message}`)); | |
| 93 | + let parsedJson: unknown; | |
| 94 | + try { | |
| 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 | 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 | 132 | run: async (input: unknown) => { |
| 101 | − const parsedInput = adminAnnouncementInputSchema.safeParse(asPayload(input)); | |
| 133 | + const parsedInput = adminAnnouncementInputSchema.safeParse(toInputPayload(input)); | |
| 102 | 134 | const channel = parsedInput.success ? (parsedInput.data.channel ?? "") : ""; |
| 103 | 135 | const message = parsedInput.success ? (parsedInput.data.message ?? "") : ""; |
| 104 | 136 | return { ok: true, channel, message }; |
| @@ -401,13 +433,6 @@ function normalizeHint(type?: string): string { · 296 unmodified lines | ||
| 401 | 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 | 436 | async function buildWorkspaceToolRegistry( |
| 412 | 437 | ctx: ActionCtx, |
| 413 | 438 | args: { |
| @@ -433,24 +458,24 @@ async function buildWorkspaceToolRegistry( · 19 unmodified lines | ||
| 433 | 458 | const normalizedPath = normalizeToolPathForLookup(st.path); |
| 434 | 459 | const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase(); |
| 435 | 460 | |
| 436 | − const inputSchema = asJsonSchema(st.typing?.inputSchema); | |
| 437 | − const outputSchema = asJsonSchema(st.typing?.outputSchema); | |
| 461 | + const inputSchema = toJsonSchema(st.typing?.inputSchema); | |
| 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) | |
| 440 | − ? st.typing!.requiredInputKeys!.filter((v): v is string => typeof v === "string") | |
| 441 | − : extractTopLevelRequiredKeys(inputSchema); | |
| 442 | − const previewInputKeys = Array.isArray(st.typing?.previewInputKeys) | |
| 443 | − ? st.typing!.previewInputKeys!.filter((v): v is string => typeof v === "string") | |
| 444 | − : buildPreviewKeys(inputSchema); | |
| 466 | + const requiredInputKeys = typing.requiredInputKeys ?? extractTopLevelRequiredKeys(inputSchema); | |
| 467 | + const previewInputKeys = typing.previewInputKeys ?? buildPreviewKeys(inputSchema); | |
| 468 | + const inputHint = typing.inputHint?.trim(); | |
| 469 | + const outputHint = typing.outputHint?.trim(); | |
| 445 | 470 | |
| 446 | − const displayInput = typeof st.typing?.inputHint === "string" && st.typing.inputHint.trim().length > 0 | |
| 447 | − ? st.typing.inputHint.trim() | |
| 471 | + const displayInput = inputHint && inputHint.length > 0 | |
| 472 | + ? inputHint | |
| 448 | 473 | : (Object.keys(inputSchema).length === 0 |
| 449 | 474 | ? "{}" |
| 450 | 475 | : normalizeHint(jsonSchemaTypeHintFallback(inputSchema))); |
| 451 | 476 | |
| 452 | − const displayOutput = typeof st.typing?.outputHint === "string" && st.typing.outputHint.trim().length > 0 | |
| 453 | − ? st.typing.outputHint.trim() | |
| 477 | + const displayOutput = outputHint && outputHint.length > 0 | |
| 478 | + ? outputHint | |
| 454 | 479 | : (Object.keys(outputSchema).length === 0 |
| 455 | 480 | ? "unknown" |
| 456 | 481 | : normalizeHint(jsonSchemaTypeHintFallback(outputSchema))); |
| @@ -525,6 +550,11 @@ async function buildWorkspaceToolRegistry( · 68 unmodified lines | ||
| 525 | 550 | buildId, |
| 526 | 551 | }); |
| 527 | 552 | |
| 553 | + await ctx.runMutation(internal.toolRegistry.pruneBuilds, { | |
| 554 | + workspaceId: args.workspaceId, | |
| 555 | + maxRetainedBuilds: 2, | |
| 556 | + }); | |
| 557 | + | |
| 528 | 558 | return { buildId }; |
| 529 | 559 | } |
| 530 | 560 | |
| @@ -553,8 +583,9 @@ export async function getWorkspaceTools( · 22 unmodified lines | ||
| 553 | 583 | if (source.type !== "mcp") { |
| 554 | 584 | return false; |
| 555 | 585 | } |
| 556 | − const auth = asPayload(source.config.auth); | |
| 557 | − return auth?.mode === "actor"; | |
| 586 | + | |
| 587 | + const auth = actorScopedMcpAuthSchema.safeParse(source.config.auth); | |
| 588 | + return auth.success; | |
| 558 | 589 | }); |
| 559 | 590 | const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource; |
| 560 | 591 | const skipCacheWrite = hasActorScopedMcpSource; |
| @@ -670,6 +701,7 @@ export async function getWorkspaceTools( · 109 unmodified lines | ||
| 670 | 701 | timedOut: boolean; |
| 671 | 702 | sourceName: string; |
| 672 | 703 | openApiDts?: string; |
| 704 | + openApiSourceKey?: string; | |
| 673 | 705 | }>((resolve) => { |
| 674 | 706 | timer = setTimeout(() => { |
| 675 | 707 | resolve({ |
| @@ -678,6 +710,7 @@ export async function getWorkspaceTools( · 2 unmodified lines | ||
| 678 | 710 | timedOut: true, |
| 679 | 711 | sourceName: config.name, |
| 680 | 712 | openApiDts: undefined, |
| 713 | + openApiSourceKey: config.type === "openapi" ? (config.sourceKey ?? `openapi:${config.name}`) : undefined, | |
| 681 | 714 | }); |
| 682 | 715 | }, sourceTimeoutMs); |
| 683 | 716 | }); |
| @@ -739,7 +772,8 @@ export async function getWorkspaceTools( · 55 unmodified lines | ||
| 739 | 772 | const openApiDtsBySource: Record<string, string> = {}; |
| 740 | 773 | for (const loaded of loadedSources) { |
| 741 | 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 | 779 | const typeBundle = buildWorkspaceTypeBundle({ |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { Result } from "better-result"; |
| 2 | 2 | import { api } from "@executor/database/convex/_generated/api"; |
| 3 | 3 | import { ConvexClient, ConvexHttpClient } from "convex/browser"; |
| 4 | +import { z } from "zod"; | |
| 4 | 5 | import type { |
| 5 | 6 | BridgeEntrypointContext, |
| 6 | 7 | BridgeProps, |
| @@ -10,33 +11,41 @@ import type { · 3 unmodified lines | ||
| 10 | 11 | |
| 11 | 12 | const APPROVAL_SUBSCRIPTION_TIMEOUT_MS = 10 * 60 * 1000; |
| 12 | 13 | |
| 13 | −function asObject(value: unknown): Record<string, unknown> { | |
| 14 | − if (value && typeof value === "object" && !Array.isArray(value)) { | |
| 15 | − return value as Record<string, unknown>; | |
| 16 | − } | |
| 17 | − return {}; | |
| 18 | −} | |
| 19 | − | |
| 20 | −function isBridgeProps(value: unknown): value is BridgeProps { | |
| 21 | − const props = asObject(value); | |
| 22 | − return ( | |
| 23 | − typeof props.callbackConvexUrl === "string" | |
| 24 | − && typeof props.callbackInternalSecret === "string" | |
| 25 | − && typeof props.taskId === "string" | |
| 26 | − ); | |
| 14 | +const bridgePropsSchema: z.ZodType<BridgeProps> = z.object({ | |
| 15 | + callbackConvexUrl: z.string(), | |
| 16 | + callbackInternalSecret: z.string(), | |
| 17 | + taskId: z.string(), | |
| 18 | +}); | |
| 19 | + | |
| 20 | +const toolCallResultSchema: z.ZodType<ToolCallResult> = z.union([ | |
| 21 | + z.object({ ok: z.literal(true), value: z.unknown().optional() }), | |
| 22 | + z.object({ | |
| 23 | + ok: z.literal(false), | |
| 24 | + kind: z.literal("pending"), | |
| 25 | + approvalId: z.string(), | |
| 26 | + error: z.string().optional(), | |
| 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 { | |
| 30 | − const record = asObject(value); | |
| 31 | − if (record.ok === true) { | |
| 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; | |
| 46 | +function asObject(value: unknown): Record<string, unknown> { | |
| 47 | + const parsed = recordSchema.safeParse(value); | |
| 48 | + return parsed.success ? parsed.data : {}; | |
| 40 | 49 | } |
| 41 | 50 | |
| 42 | 51 | export function getBridgePropsFromContext( |
| @@ -47,11 +56,12 @@ export function getBridgePropsFromContext( · 4 unmodified lines | ||
| 47 | 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 | 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 | 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 | 75 | const exportsObject = asObject(exportsValue); |
| 66 | − if (typeof exportsObject.ToolBridge !== "function") { | |
| 76 | + if (!hasToolBridgeExport(exportsObject)) { | |
| 67 | 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 | 83 | function createConvexClient(callbackConvexUrl: string): ConvexHttpClient { |
| @@ -157,16 +167,13 @@ export async function callToolWithBridge( · 83 unmodified lines | ||
| 157 | 167 | return { ok: false, kind: "failed", error: `Tool callback failed: ${message}` }; |
| 158 | 168 | } |
| 159 | 169 | |
| 160 | − const result = response.value; | |
| 161 | − if (!isToolCallResult(result)) { | |
| 170 | + const parsedResult = toolCallResultSchema.safeParse(response.value); | |
| 171 | + if (!parsedResult.success) { | |
| 162 | 172 | return { ok: false, kind: "failed", error: "Tool callback returned invalid result payload" }; |
| 163 | 173 | } |
| 174 | + const result = parsedResult.data; | |
| 164 | 175 | |
| 165 | 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 | 177 | const approvalId = result.approvalId; |
| 171 | 178 | const wait = await Result.tryPromise(() => waitForApprovalUpdate(props, approvalId)); |
| 172 | 179 | if (wait.isErr()) { |
| @@ -1,19 +1,31 @@ | ||
| 1 | −import { Result } from "better-result"; | |
| 1 | +import { z } from "zod"; | |
| 2 | 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 | 14 | export async function parseRunRequest(request: Request): Promise<RunRequest | Response> { |
| 5 | − const parsed = await Result.tryPromise(() => request.json() as Promise<RunRequest>); | |
| 6 | − if (parsed.isErr()) { | |
| 15 | + let rawBody: unknown; | |
| 16 | + try { | |
| 17 | + rawBody = await request.json(); | |
| 18 | + } catch { | |
| 7 | 19 | return Response.json({ error: "Invalid JSON body" }, { status: 400 }); |
| 8 | 20 | } |
| 9 | 21 | |
| 10 | − const body = parsed.value; | |
| 11 | − if (!body.taskId || !body.code || !body.callback?.convexUrl || !body.callback?.internalSecret) { | |
| 22 | + const body = runRequestSchema.safeParse(rawBody); | |
| 23 | + if (!body.success) { | |
| 12 | 24 | return Response.json( |
| 13 | 25 | { error: "Missing required fields: taskId, code, callback.convexUrl, callback.internalSecret" }, |
| 14 | 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 | 2 | import type { Env, RunRequest, RunResult } from "./types"; |
| 3 | 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 | 13 | * Build the user code module. The code is wrapped in an exported async |
| 7 | 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 | 58 | const controller = new AbortController(); |
| 52 | 59 | const timer = setTimeout(() => controller.abort(), timeoutMs); |
| 53 | 60 | |
| 54 | − const response = await Result.tryPromise(() => | |
| 55 | − entrypoint.fetch("http://sandbox.internal/run", { | |
| 61 | + let response: Response; | |
| 62 | + try { | |
| 63 | + response = await entrypoint.fetch("http://sandbox.internal/run", { | |
| 56 | 64 | method: "POST", |
| 57 | 65 | signal: controller.signal, |
| 58 | − }), | |
| 59 | − ); | |
| 60 | − | |
| 61 | − clearTimeout(timer); | |
| 62 | − | |
| 63 | − if (response.isErr()) { | |
| 64 | − const cause = response.error.cause; | |
| 65 | − if (cause instanceof DOMException && cause.name === "AbortError") { | |
| 66 | + }); | |
| 67 | + } catch (error) { | |
| 68 | + clearTimeout(timer); | |
| 69 | + if (error instanceof DOMException && error.name === "AbortError") { | |
| 66 | 70 | return { |
| 67 | 71 | status: "timed_out", |
| 68 | 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>); | |
| 75 | − if (body.isErr()) { | |
| 78 | + clearTimeout(timer); | |
| 79 | + | |
| 80 | + let body: unknown; | |
| 81 | + try { | |
| 82 | + body = await response.json(); | |
| 83 | + } catch { | |
| 76 | 84 | return { |
| 77 | 85 | status: "failed", |
| 78 | 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 | 28 | export interface RunRequest { |
| 29 | 29 | taskId: string; |
| 30 | 30 | code: string; |
| 31 | − timeoutMs: number; | |
| 31 | + timeoutMs?: number; | |
| 32 | 32 | callback: { |
| 33 | 33 | convexUrl: string; |
| 34 | 34 | internalSecret: string; |
| @@ -42,14 +42,25 @@ export interface RunResult { · 7 unmodified lines | ||
| 42 | 42 | exitCode?: number; |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | −export interface ToolCallResult { | |
| 46 | − ok: true | false; | |
| 47 | − value?: unknown; | |
| 48 | − error?: string; | |
| 49 | − kind?: "pending" | "denied" | "failed"; | |
| 50 | − approvalId?: string; | |
| 51 | − retryAfterMs?: number; | |
| 52 | −} | |
| 45 | +export type ToolCallResult = | |
| 46 | + | { | |
| 47 | + ok: true; | |
| 48 | + value?: unknown; | |
| 49 | + } | |
| 50 | + | { | |
| 51 | + ok: false; | |
| 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 | 65 | export interface BridgeProps { |
| 55 | 66 | callbackConvexUrl: string; |
| @@ -1,18 +1,18 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "$schema": "https://opencode.ai/config.json", |
| 3 | − "mcp":{ | |
| 3 | + "mcp": { | |
| 4 | 4 | "answeroverflow": { |
| 5 | − "type": "remote", | |
| 6 | − "url": "https://www.answeroverflow.com/mcp", | |
| 7 | − "enabled": true | |
| 8 | − }, | |
| 9 | − "oracle": { | |
| 10 | − "type": "local", | |
| 11 | − "command": ["npx", "-y", "@steipete/oracle", "oracle-mcp"], | |
| 12 | − "enabled": true | |
| 13 | − } | |
| 5 | + "type": "remote", | |
| 6 | + "url": "https://www.answeroverflow.com/mcp", | |
| 7 | + "enabled": true | |
| 8 | + }, | |
| 9 | + "oracle": { | |
| 10 | + "type": "local", | |
| 11 | + "command": ["npx", "-y", "@steipete/oracle", "oracle-mcp"], | |
| 12 | + "enabled": true | |
| 13 | + } | |
| 14 | 14 | }, |
| 15 | − "agent":{ | |
| 15 | + "agent": { | |
| 16 | 16 | "quick-build": { |
| 17 | 17 | "mode": "primary", |
| 18 | 18 | "description": "A smaller context winder but extremely fast at working", |
| @@ -22,6 +22,16 @@ · 3 unmodified lines | ||
| 22 | 22 | "edit": true, |
| 23 | 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 | } |