Split Convex executor/http logic into focused modules and carry source auth metadata through tool discovery and runtime paths. Add self-issued OAuth routes so MCP auth can run without an external authorization server.
Unified · Split
| @@ -26,6 +26,10 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 25 unmodified lines | ||
| 26 | 26 | # EXECUTOR_BACKEND_SITE_PORT=5411 |
| 27 | 27 | # EXECUTOR_WEB_PORT=5312 |
| 28 | 28 | |
| 29 | +# ── MCP OAuth (optional) ── | |
| 30 | +# Set to 1 to enable self-issued anonymous OAuth even without WorkOS auth. | |
| 31 | +# MCP_ENABLE_ANONYMOUS_OAUTH=1 | |
| 32 | + | |
| 29 | 33 | # ── Discord bot (optional — omit to skip bot startup) ── |
| 30 | 34 | # DISCORD_BOT_TOKEN=... |
| 31 | 35 | |
| @@ -10,3 +10,5 @@ dist · 9 unmodified lines | ||
| 10 | 10 | .vercel |
| 11 | 11 | .env*.local |
| 12 | 12 | .convex |
| 13 | +**/.wrangler | |
| 14 | +**/.dev.vars | |
| @@ -121,10 +121,13 @@ Convex HTTP routes (`packages/convex/http.ts`) expose: · 120 unmodified lines | ||
| 121 | 121 | - `/mcp` (direct Convex MCP transport) |
| 122 | 122 | - `/.well-known/oauth-protected-resource` |
| 123 | 123 | - `/.well-known/oauth-authorization-server` |
| 124 | +- `/oauth2/jwks` (self-issued anonymous OAuth) | |
| 125 | +- `/register` (anonymous OAuth dynamic client registration) | |
| 126 | +- `/authorize` (anonymous OAuth authorization endpoint) | |
| 127 | +- `/token` (anonymous OAuth token exchange) | |
| 124 | 128 | - `/internal/runs/:runId/tool-call` |
| 125 | −- `/internal/runs/:runId/output` | |
| 126 | 129 | |
| 127 | −MCP bearer-token verification is enabled when `MCP_AUTHORIZATION_SERVER` (or `MCP_AUTHORIZATION_SERVER_URL`) is configured. | |
| 130 | +MCP bearer-token verification is enabled when `MCP_AUTHORIZATION_SERVER` / `MCP_AUTHORIZATION_SERVER_URL` is configured, or when `MCP_ENABLE_ANONYMOUS_OAUTH=1`. | |
| 128 | 131 | |
| 129 | 132 | ## Configuration Reference |
| 130 | 133 | |
| @@ -144,6 +147,7 @@ Important env vars (see root `.env.example` for the base template): · 13 unmodified lines | ||
| 144 | 147 | - `STRIPE_PRICE_ID` |
| 145 | 148 | - MCP auth integration: |
| 146 | 149 | - `MCP_AUTHORIZATION_SERVER` or `MCP_AUTHORIZATION_SERVER_URL` |
| 150 | + - `MCP_ENABLE_ANONYMOUS_OAUTH` (`1` to enable anonymous OAuth without external auth server) | |
| 147 | 151 | - Managed runtime: |
| 148 | 152 | - `EXECUTOR_RUNTIME_DIR` |
| 149 | 153 | - `EXECUTOR_BACKEND_PORT` |
| @@ -29,6 +29,18 @@ export declare const api: { · 28 unmodified lines | ||
| 29 | 29 | >; |
| 30 | 30 | }; |
| 31 | 31 | anonymousOauth: { |
| 32 | + consumeAuthorizationCode: FunctionReference< | |
| 33 | + "mutation", | |
| 34 | + "public", | |
| 35 | + { code: string; internalSecret: string }, | |
| 36 | + any | |
| 37 | + >; | |
| 38 | + countAuthorizationCodes: FunctionReference< | |
| 39 | + "query", | |
| 40 | + "public", | |
| 41 | + { internalSecret: string }, | |
| 42 | + any | |
| 43 | + >; | |
| 32 | 44 | getActiveSigningKey: FunctionReference< |
| 33 | 45 | "query", |
| 34 | 46 | "public", |
| @@ -41,6 +53,12 @@ export declare const api: { · 6 unmodified lines | ||
| 41 | 53 | { clientId: string; internalSecret: string }, |
| 42 | 54 | any |
| 43 | 55 | >; |
| 56 | + purgeExpiredAuthorizationCodes: FunctionReference< | |
| 57 | + "mutation", | |
| 58 | + "public", | |
| 59 | + { internalSecret: string; now: number }, | |
| 60 | + any | |
| 61 | + >; | |
| 44 | 62 | registerClient: FunctionReference< |
| 45 | 63 | "mutation", |
| 46 | 64 | "public", |
| @@ -52,6 +70,23 @@ export declare const api: { · 5 unmodified lines | ||
| 52 | 70 | }, |
| 53 | 71 | any |
| 54 | 72 | >; |
| 73 | + storeAuthorizationCode: FunctionReference< | |
| 74 | + "mutation", | |
| 75 | + "public", | |
| 76 | + { | |
| 77 | + actorId: string; | |
| 78 | + clientId: string; | |
| 79 | + code: string; | |
| 80 | + codeChallenge: string; | |
| 81 | + codeChallengeMethod: string; | |
| 82 | + createdAt: number; | |
| 83 | + expiresAt: number; | |
| 84 | + internalSecret: string; | |
| 85 | + redirectUri: string; | |
| 86 | + tokenClaims?: any; | |
| 87 | + }, | |
| 88 | + any | |
| 89 | + >; | |
| 55 | 90 | storeSigningKey: FunctionReference< |
| 56 | 91 | "mutation", |
| 57 | 92 | "public", |
| @@ -125,7 +160,7 @@ export declare const api: { · 67 unmodified lines | ||
| 125 | 160 | { |
| 126 | 161 | actorId?: string; |
| 127 | 162 | id?: string; |
| 128 | − provider?: "managed" | "workos-vault"; | |
| 163 | + provider?: "local-convex" | "workos-vault"; | |
| 129 | 164 | scope: "workspace" | "actor"; |
| 130 | 165 | secretJson: any; |
| 131 | 166 | sessionId?: string; |
| @@ -166,6 +201,12 @@ export declare const api: { · 34 unmodified lines | ||
| 166 | 201 | >; |
| 167 | 202 | }; |
| 168 | 203 | executorNode: { |
| 204 | + listToolDtsUrls: FunctionReference< | |
| 205 | + "action", | |
| 206 | + "public", | |
| 207 | + { actorId?: string; sessionId?: string; workspaceId: Id<"workspaces"> }, | |
| 208 | + any | |
| 209 | + >; | |
| 169 | 210 | listToolsWithWarnings: FunctionReference< |
| 170 | 211 | "action", |
| 171 | 212 | "public", |
| @@ -277,18 +318,6 @@ export declare const api: { · 105 unmodified lines | ||
| 277 | 318 | >; |
| 278 | 319 | }; |
| 279 | 320 | runtimeCallbacks: { |
| 280 | − appendOutput: FunctionReference< | |
| 281 | − "mutation", | |
| 282 | − "public", | |
| 283 | − { | |
| 284 | − internalSecret: string; | |
| 285 | − line: string; | |
| 286 | − runId: string; | |
| 287 | − stream: "stdout" | "stderr"; | |
| 288 | − timestamp?: number; | |
| 289 | − }, | |
| 290 | − any | |
| 291 | − >; | |
| 292 | 321 | completeRun: FunctionReference< |
| 293 | 322 | "mutation", |
| 294 | 323 | "public", |
| @@ -297,10 +326,9 @@ export declare const api: { · 2 unmodified lines | ||
| 297 | 326 | error?: string; |
| 298 | 327 | exitCode?: number; |
| 299 | 328 | internalSecret: string; |
| 329 | + result?: any; | |
| 300 | 330 | runId: string; |
| 301 | 331 | status: "completed" | "failed" | "timed_out" | "denied"; |
| 302 | − stderr?: string; | |
| 303 | − stdout?: string; | |
| 304 | 332 | }, |
| 305 | 333 | any |
| 306 | 334 | >; |
| @@ -407,7 +435,8 @@ export declare const api: { · 100 unmodified lines | ||
| 407 | 435 | { |
| 408 | 436 | actorId?: string; |
| 409 | 437 | id?: string; |
| 410 | − provider?: "managed" | "workos-vault"; | |
| 438 | + overridesJson?: any; | |
| 439 | + provider?: "local-convex" | "workos-vault"; | |
| 411 | 440 | scope: "workspace" | "actor"; |
| 412 | 441 | secretJson: any; |
| 413 | 442 | sessionId?: string; |
| @@ -528,6 +557,18 @@ export declare const internal: { · 114 unmodified lines | ||
| 528 | 557 | { sessionId?: string }, |
| 529 | 558 | any |
| 530 | 559 | >; |
| 560 | + consumeAnonymousOauthAuthorizationCode: FunctionReference< | |
| 561 | + "mutation", | |
| 562 | + "internal", | |
| 563 | + { code: string }, | |
| 564 | + any | |
| 565 | + >; | |
| 566 | + countAnonymousOauthAuthorizationCodes: FunctionReference< | |
| 567 | + "query", | |
| 568 | + "internal", | |
| 569 | + {}, | |
| 570 | + any | |
| 571 | + >; | |
| 531 | 572 | createApproval: FunctionReference< |
| 532 | 573 | "mutation", |
| 533 | 574 | "internal", |
| @@ -567,7 +608,6 @@ export declare const internal: { · 33 unmodified lines | ||
| 567 | 608 | { |
| 568 | 609 | callId: string; |
| 569 | 610 | error?: string; |
| 570 | − output?: any; | |
| 571 | 611 | status: "completed" | "failed" | "denied"; |
| 572 | 612 | taskId: string; |
| 573 | 613 | }, |
| @@ -675,9 +715,8 @@ export declare const internal: { · 101 unmodified lines | ||
| 675 | 715 | { |
| 676 | 716 | error?: string; |
| 677 | 717 | exitCode?: number; |
| 718 | + result?: any; | |
| 678 | 719 | status: "completed" | "failed" | "timed_out" | "denied"; |
| 679 | − stderr: string; | |
| 680 | − stdout: string; | |
| 681 | 720 | taskId: string; |
| 682 | 721 | }, |
| 683 | 722 | any |
| @@ -688,6 +727,12 @@ export declare const internal: { · 4 unmodified lines | ||
| 688 | 727 | { taskId: string }, |
| 689 | 728 | any |
| 690 | 729 | >; |
| 730 | + purgeExpiredAnonymousOauthAuthorizationCodes: FunctionReference< | |
| 731 | + "mutation", | |
| 732 | + "internal", | |
| 733 | + { now: number }, | |
| 734 | + any | |
| 735 | + >; | |
| 691 | 736 | registerAnonymousOauthClient: FunctionReference< |
| 692 | 737 | "mutation", |
| 693 | 738 | "internal", |
| @@ -722,6 +767,22 @@ export declare const internal: { · 28 unmodified lines | ||
| 722 | 767 | { approvalId: string; callId: string; taskId: string }, |
| 723 | 768 | any |
| 724 | 769 | >; |
| 770 | + storeAnonymousOauthAuthorizationCode: FunctionReference< | |
| 771 | + "mutation", | |
| 772 | + "internal", | |
| 773 | + { | |
| 774 | + actorId: string; | |
| 775 | + clientId: string; | |
| 776 | + code: string; | |
| 777 | + codeChallenge: string; | |
| 778 | + codeChallengeMethod: string; | |
| 779 | + createdAt: number; | |
| 780 | + expiresAt: number; | |
| 781 | + redirectUri: string; | |
| 782 | + tokenClaims?: any; | |
| 783 | + }, | |
| 784 | + any | |
| 785 | + >; | |
| 725 | 786 | storeAnonymousOauthSigningKey: FunctionReference< |
| 726 | 787 | "mutation", |
| 727 | 788 | "internal", |
| @@ -753,7 +814,8 @@ export declare const internal: { · 25 unmodified lines | ||
| 753 | 814 | { |
| 754 | 815 | actorId?: string; |
| 755 | 816 | id?: string; |
| 756 | − provider?: "managed" | "workos-vault"; | |
| 817 | + overridesJson?: any; | |
| 818 | + provider?: "local-convex" | "workos-vault"; | |
| 757 | 819 | scope: "workspace" | "actor"; |
| 758 | 820 | secretJson: any; |
| 759 | 821 | sourceKey: string; |
| @@ -766,7 +828,6 @@ export declare const internal: { · 6 unmodified lines | ||
| 766 | 828 | "internal", |
| 767 | 829 | { |
| 768 | 830 | callId: string; |
| 769 | − input?: any; | |
| 770 | 831 | taskId: string; |
| 771 | 832 | toolPath: string; |
| 772 | 833 | workspaceId: Id<"workspaces">; |
| @@ -788,17 +849,6 @@ export declare const internal: { · 15 unmodified lines | ||
| 788 | 849 | >; |
| 789 | 850 | }; |
| 790 | 851 | executor: { |
| 791 | − appendRuntimeOutput: FunctionReference< | |
| 792 | − "mutation", | |
| 793 | − "internal", | |
| 794 | − { | |
| 795 | − line: string; | |
| 796 | − runId: string; | |
| 797 | − stream: "stdout" | "stderr"; | |
| 798 | − timestamp?: number; | |
| 799 | − }, | |
| 800 | − any | |
| 801 | − >; | |
| 802 | 852 | completeRuntimeRun: FunctionReference< |
| 803 | 853 | "mutation", |
| 804 | 854 | "internal", |
| @@ -806,10 +856,9 @@ export declare const internal: { · 1 unmodified line | ||
| 806 | 856 | durationMs?: number; |
| 807 | 857 | error?: string; |
| 808 | 858 | exitCode?: number; |
| 859 | + result?: any; | |
| 809 | 860 | runId: string; |
| 810 | 861 | status: "completed" | "failed" | "timed_out" | "denied"; |
| 811 | − stderr?: string; | |
| 812 | − stdout?: string; | |
| 813 | 862 | }, |
| 814 | 863 | any |
| 815 | 864 | >; |
| @@ -83,3 +83,61 @@ export const getClient = query({ · 82 unmodified lines | ||
| 83 | 83 | ); |
| 84 | 84 | }, |
| 85 | 85 | }); |
| 86 | + | |
| 87 | +// ── Authorization Codes ───────────────────────────────────────────────────── | |
| 88 | + | |
| 89 | +export const storeAuthorizationCode = mutation({ | |
| 90 | + args: { | |
| 91 | + internalSecret: v.string(), | |
| 92 | + code: v.string(), | |
| 93 | + clientId: v.string(), | |
| 94 | + redirectUri: v.string(), | |
| 95 | + codeChallenge: v.string(), | |
| 96 | + codeChallengeMethod: v.string(), | |
| 97 | + actorId: v.string(), | |
| 98 | + tokenClaims: v.optional(v.any()), | |
| 99 | + expiresAt: v.number(), | |
| 100 | + createdAt: v.number(), | |
| 101 | + }, | |
| 102 | + handler: async (ctx, args) => { | |
| 103 | + requireInternalSecret(args.internalSecret); | |
| 104 | + const { internalSecret: _, ...payload } = args; | |
| 105 | + await ctx.runMutation(internal.database.storeAnonymousOauthAuthorizationCode, payload); | |
| 106 | + }, | |
| 107 | +}); | |
| 108 | + | |
| 109 | +export const consumeAuthorizationCode = mutation({ | |
| 110 | + args: { | |
| 111 | + internalSecret: v.string(), | |
| 112 | + code: v.string(), | |
| 113 | + }, | |
| 114 | + handler: async (ctx, args) => { | |
| 115 | + requireInternalSecret(args.internalSecret); | |
| 116 | + return await ctx.runMutation(internal.database.consumeAnonymousOauthAuthorizationCode, { | |
| 117 | + code: args.code, | |
| 118 | + }); | |
| 119 | + }, | |
| 120 | +}); | |
| 121 | + | |
| 122 | +export const purgeExpiredAuthorizationCodes = mutation({ | |
| 123 | + args: { | |
| 124 | + internalSecret: v.string(), | |
| 125 | + now: v.number(), | |
| 126 | + }, | |
| 127 | + handler: async (ctx, args) => { | |
| 128 | + requireInternalSecret(args.internalSecret); | |
| 129 | + return await ctx.runMutation(internal.database.purgeExpiredAnonymousOauthAuthorizationCodes, { | |
| 130 | + now: args.now, | |
| 131 | + }); | |
| 132 | + }, | |
| 133 | +}); | |
| 134 | + | |
| 135 | +export const countAuthorizationCodes = query({ | |
| 136 | + args: { | |
| 137 | + internalSecret: v.string(), | |
| 138 | + }, | |
| 139 | + handler: async (ctx, args) => { | |
| 140 | + requireInternalSecret(args.internalSecret); | |
| 141 | + return await ctx.runQuery(internal.database.countAnonymousOauthAuthorizationCodes, {}); | |
| 142 | + }, | |
| 143 | +}); | |
| @@ -3,6 +3,7 @@ import { components, internal } from "./_generated/api"; · 2 unmodified lines | ||
| 3 | 3 | import type { DataModel, Doc, Id } from "./_generated/dataModel"; |
| 4 | 4 | import type { MutationCtx } from "./_generated/server"; |
| 5 | 5 | import { internalMutation, mutation } from "./_generated/server"; |
| 6 | +import { slugify } from "../core/src/identity"; | |
| 6 | 7 | import { ensureUniqueSlug } from "../core/src/slug"; |
| 7 | 8 | |
| 8 | 9 | type DbCtx = Pick<MutationCtx, "db">; |
| @@ -48,14 +49,6 @@ export const authKit = · 39 unmodified lines | ||
| 48 | 49 | registerRoutes: () => {}, |
| 49 | 50 | } as Pick<AuthKit<DataModel>, "registerRoutes">); |
| 50 | 51 | |
| 51 | −function slugify(input: string): string { | |
| 52 | − const slug = input | |
| 53 | − .toLowerCase() | |
| 54 | − .replace(/[^a-z0-9]+/g, "-") | |
| 55 | − .replace(/^-+|-+$/g, ""); | |
| 56 | − return slug.length > 0 ? slug : "workspace"; | |
| 57 | −} | |
| 58 | − | |
| 59 | 52 | function titleCaseWords(input: string): string { |
| 60 | 53 | return input |
| 61 | 54 | .split(" ") |
| @@ -155,7 +148,7 @@ async function getFirstWorkspaceByOrganizationId(ctx: DbCtx, organizationId: Id< · 93 unmodified lines | ||
| 155 | 148 | } |
| 156 | 149 | |
| 157 | 150 | async function ensureUniqueOrganizationSlug(ctx: DbCtx, baseName: string): Promise<string> { |
| 158 | − const baseSlug = slugify(baseName); | |
| 151 | + const baseSlug = slugify(baseName, "workspace"); | |
| 159 | 152 | return await ensureUniqueSlug(baseSlug, async (candidate) => { |
| 160 | 153 | const collision = await ctx.db |
| 161 | 154 | .query("organizations") |
| @@ -333,7 +326,7 @@ async function ensurePersonalWorkspace( · 171 unmodified lines | ||
| 333 | 326 | updatedAt: opts.now, |
| 334 | 327 | }); |
| 335 | 328 | |
| 336 | − const baseSlug = slugify(opts.email.split("@")[0] ?? opts.workosUserId); | |
| 329 | + const baseSlug = slugify(opts.email.split("@")[0] ?? opts.workosUserId, "workspace"); | |
| 337 | 330 | const workspaceId = await ctx.db.insert("workspaces", { |
| 338 | 331 | organizationId, |
| 339 | 332 | slug: `${baseSlug}-${opts.workosUserId.slice(-6)}`, |
| @@ -6,7 +6,9 @@ import { internal } from "./_generated/api"; · 5 unmodified lines | ||
| 6 | 6 | import { action } from "./_generated/server"; |
| 7 | 7 | |
| 8 | 8 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); |
| 9 | −const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault")); | |
| 9 | +const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault")); | |
| 10 | + | |
| 11 | +type SecretBackend = "local-convex" | "workos-vault"; | |
| 10 | 12 | |
| 11 | 13 | function asRecord(value: unknown): Record<string, unknown> { |
| 12 | 14 | if (value && typeof value === "object" && !Array.isArray(value)) { |
| @@ -21,6 +23,17 @@ function normalizedActorId(scope: "workspace" | "actor", actorId?: string): stri · 8 unmodified lines | ||
| 21 | 23 | return actorId.trim(); |
| 22 | 24 | } |
| 23 | 25 | |
| 26 | +function configuredSecretBackend(): SecretBackend { | |
| 27 | + const explicit = process.env.EXECUTOR_SECRET_BACKEND?.trim().toLowerCase(); | |
| 28 | + if (explicit === "workos" || explicit === "workos-vault") { | |
| 29 | + return "workos-vault"; | |
| 30 | + } | |
| 31 | + if (explicit === "local" || explicit === "local-convex") { | |
| 32 | + return "local-convex"; | |
| 33 | + } | |
| 34 | + return process.env.WORKOS_API_KEY?.trim() ? "workos-vault" : "local-convex"; | |
| 35 | +} | |
| 36 | + | |
| 24 | 37 | function extractObjectId(secretJson: Record<string, unknown>): string | null { |
| 25 | 38 | const candidate = |
| 26 | 39 | (typeof secretJson.objectId === "string" ? secretJson.objectId : "") || |
| @@ -29,13 +42,24 @@ function extractObjectId(secretJson: Record<string, unknown>): string | null { · 2 unmodified lines | ||
| 29 | 42 | return trimmed.length > 0 ? trimmed : null; |
| 30 | 43 | } |
| 31 | 44 | |
| 32 | −function stripObjectRef(secretJson: Record<string, unknown>): Record<string, unknown> { | |
| 33 | − const { objectId: _objectId, id: _id, ...rest } = secretJson; | |
| 34 | − return asRecord(rest); | |
| 35 | −} | |
| 45 | +function extractHeaderOverrides(secretJson: Record<string, unknown>): { | |
| 46 | + cleanSecret: Record<string, unknown>; | |
| 47 | + overridesJson: Record<string, unknown>; | |
| 48 | +} { | |
| 49 | + const rawHeaders = asRecord(secretJson.__headers); | |
| 50 | + const headers: Record<string, string> = {}; | |
| 51 | + for (const [name, value] of Object.entries(rawHeaders)) { | |
| 52 | + if (!name) continue; | |
| 53 | + const text = typeof value === "string" ? value.trim() : String(value ?? "").trim(); | |
| 54 | + if (!text) continue; | |
| 55 | + headers[name.trim()] = text; | |
| 56 | + } | |
| 36 | 57 | |
| 37 | −function hasPayload(secretJson: Record<string, unknown>): boolean { | |
| 38 | − return Object.keys(stripObjectRef(secretJson)).length > 0; | |
| 58 | + const { __headers: _headers, ...rest } = secretJson; | |
| 59 | + return { | |
| 60 | + cleanSecret: asRecord(rest), | |
| 61 | + overridesJson: Object.keys(headers).length > 0 ? { headers } : {}, | |
| 62 | + }; | |
| 39 | 63 | } |
| 40 | 64 | |
| 41 | 65 | function buildVaultObjectName(args: { |
| @@ -50,7 +74,7 @@ function buildVaultObjectName(args: { · 8 unmodified lines | ||
| 50 | 74 | .replace(/^-+|-+$/g, "") |
| 51 | 75 | .slice(0, 64); |
| 52 | 76 | |
| 53 | − return `executor-cred-${args.workspaceId.slice(0, 24)}-${sourceSegment}-${actorSegment.slice(0, 24)}-${crypto.randomUUID().slice(0, 8)}`; | |
| 77 | + return `executor-conn-${args.workspaceId.slice(0, 24)}-${sourceSegment}-${actorSegment.slice(0, 24)}-${crypto.randomUUID().slice(0, 8)}`; | |
| 54 | 78 | } |
| 55 | 79 | |
| 56 | 80 | function workosClient(): WorkOS { |
| @@ -162,19 +186,58 @@ export const upsertCredential = action({ · 105 unmodified lines | ||
| 162 | 186 | throw new Error("actorId must match the authenticated workspace actor"); |
| 163 | 187 | } |
| 164 | 188 | |
| 165 | − const provider = args.provider ?? "managed"; | |
| 166 | 189 | const actorId = normalizedActorId(args.scope, args.actorId ?? canonicalActorId); |
| 167 | − const submittedSecret = asRecord(args.secretJson); | |
| 190 | + const rawSubmittedSecret = asRecord(args.secretJson); | |
| 191 | + const { cleanSecret: submittedSecret, overridesJson } = extractHeaderOverrides(rawSubmittedSecret); | |
| 192 | + | |
| 193 | + const existingBinding = await ctx.runQuery(internal.database.resolveCredential, { | |
| 194 | + workspaceId: args.workspaceId, | |
| 195 | + sourceKey: args.sourceKey, | |
| 196 | + scope: args.scope, | |
| 197 | + ...(args.scope === "actor" ? { actorId } : {}), | |
| 198 | + }); | |
| 199 | + | |
| 200 | + const allCredentials = await ctx.runQuery(internal.database.listCredentials, { | |
| 201 | + workspaceId: args.workspaceId, | |
| 202 | + }) as Array<Record<string, unknown>>; | |
| 203 | + const requestedId = args.id?.trim(); | |
| 204 | + const existingConnection = requestedId | |
| 205 | + ? allCredentials.find((credential) => { | |
| 206 | + const id = String(credential.id ?? ""); | |
| 207 | + const bindingId = String(credential.bindingId ?? ""); | |
| 208 | + if (id !== requestedId && bindingId !== requestedId) return false; | |
| 209 | + if (String(credential.scope ?? "") !== args.scope) return false; | |
| 210 | + if (args.scope === "actor") { | |
| 211 | + return String(credential.actorId ?? "") === actorId; | |
| 212 | + } | |
| 213 | + return true; | |
| 214 | + }) ?? allCredentials.find((credential) => { | |
| 215 | + const id = String(credential.id ?? ""); | |
| 216 | + const bindingId = String(credential.bindingId ?? ""); | |
| 217 | + return id === requestedId || bindingId === requestedId; | |
| 218 | + }) | |
| 219 | + : null; | |
| 220 | + const connectionId = String(existingConnection?.id ?? requestedId ?? "").trim() || undefined; | |
| 221 | + | |
| 222 | + const backend = configuredSecretBackend(); | |
| 223 | + | |
| 224 | + if (backend === "local-convex") { | |
| 225 | + const finalSecret = Object.keys(submittedSecret).length > 0 | |
| 226 | + ? submittedSecret | |
| 227 | + : asRecord(existingConnection?.secretJson ?? existingBinding?.secretJson); | |
| 228 | + if (Object.keys(finalSecret).length === 0) { | |
| 229 | + throw new Error("Credential values are required"); | |
| 230 | + } | |
| 168 | 231 | |
| 169 | − if (provider === "managed") { | |
| 170 | 232 | return await ctx.runMutation(internal.database.upsertCredential, { |
| 171 | − id: args.id, | |
| 233 | + id: connectionId, | |
| 172 | 234 | workspaceId: args.workspaceId, |
| 173 | 235 | sourceKey: args.sourceKey, |
| 174 | 236 | scope: args.scope, |
| 175 | − actorId, | |
| 176 | − provider, | |
| 177 | − secretJson: submittedSecret, | |
| 237 | + ...(args.scope === "actor" ? { actorId } : {}), | |
| 238 | + provider: "local-convex", | |
| 239 | + secretJson: finalSecret, | |
| 240 | + overridesJson, | |
| 178 | 241 | }); |
| 179 | 242 | } |
| 180 | 243 | |
| @@ -183,28 +246,19 @@ export const upsertCredential = action({ · 2 unmodified lines | ||
| 183 | 246 | throw new Error("Encrypted storage value looks like a GitHub token. Paste the token in the token field."); |
| 184 | 247 | } |
| 185 | 248 | |
| 186 | − const existing: Record<string, unknown> | null = await ctx.runQuery(internal.database.resolveCredential, { | |
| 187 | − workspaceId: args.workspaceId, | |
| 188 | − sourceKey: args.sourceKey, | |
| 189 | − scope: args.scope, | |
| 190 | − ...(args.scope === "actor" ? { actorId } : {}), | |
| 191 | − }); | |
| 192 | − const existingObjectId: string | null = existing ? extractObjectId(asRecord(existing.secretJson)) : null; | |
| 193 | − | |
| 194 | − const objectId = (() => { | |
| 195 | − if (submittedObjectId) return submittedObjectId; | |
| 196 | − return null; | |
| 197 | − })(); | |
| 249 | + const existingObjectId = extractObjectId( | |
| 250 | + asRecord(existingConnection?.secretJson ?? existingBinding?.secretJson), | |
| 251 | + ); | |
| 198 | 252 | |
| 199 | − let finalObjectId = objectId; | |
| 200 | − if (!finalObjectId && hasPayload(submittedSecret)) { | |
| 253 | + let finalObjectId = submittedObjectId; | |
| 254 | + if (!finalObjectId && Object.keys(submittedSecret).length > 0) { | |
| 201 | 255 | finalObjectId = await upsertVaultObject({ |
| 202 | 256 | workspaceId: args.workspaceId, |
| 203 | 257 | sourceKey: args.sourceKey, |
| 204 | 258 | scope: args.scope, |
| 205 | 259 | actorId, |
| 206 | 260 | existingObjectId, |
| 207 | − payload: stripObjectRef(submittedSecret), | |
| 261 | + payload: submittedSecret, | |
| 208 | 262 | }); |
| 209 | 263 | } |
| 210 | 264 | |
| @@ -213,17 +267,18 @@ export const upsertCredential = action({ · 2 unmodified lines | ||
| 213 | 267 | } |
| 214 | 268 | |
| 215 | 269 | if (!finalObjectId) { |
| 216 | − throw new Error("Encrypted storage requires credential values"); | |
| 270 | + throw new Error("Credential values are required"); | |
| 217 | 271 | } |
| 218 | 272 | |
| 219 | 273 | return await ctx.runMutation(internal.database.upsertCredential, { |
| 220 | − id: args.id, | |
| 274 | + id: connectionId, | |
| 221 | 275 | workspaceId: args.workspaceId, |
| 222 | 276 | sourceKey: args.sourceKey, |
| 223 | 277 | scope: args.scope, |
| 224 | 278 | ...(args.scope === "actor" ? { actorId } : {}), |
| 225 | − provider, | |
| 279 | + provider: "workos-vault", | |
| 226 | 280 | secretJson: { objectId: finalObjectId }, |
| 281 | + overridesJson, | |
| 227 | 282 | }); |
| 228 | 283 | }, |
| 229 | 284 | }); |
| @@ -56,11 +56,10 @@ test("task lifecycle supports queue, run, and complete", async () => { · 55 unmodified lines | ||
| 56 | 56 | const secondRun = await t.mutation(internal.database.markTaskRunning, { taskId: "task_1" }); |
| 57 | 57 | expect(secondRun).toBeNull(); |
| 58 | 58 | |
| 59 | − const finished = await t.mutation(internal.database.markTaskFinished, { | |
| 59 | + const finished = await t.mutation(internal.database.markTaskFinished as any, { | |
| 60 | 60 | taskId: "task_1", |
| 61 | 61 | status: "completed", |
| 62 | − stdout: "ok", | |
| 63 | − stderr: "", | |
| 62 | + result: { ok: true }, | |
| 64 | 63 | exitCode: 0, |
| 65 | 64 | }); |
| 66 | 65 | expect(finished?.status).toBe("completed"); |
| @@ -169,11 +168,11 @@ test("credentials persist provider and resolve by scope", async () => { · 102 unmodified lines | ||
| 169 | 168 | workspaceId: wsId, |
| 170 | 169 | sourceKey: "openapi:github", |
| 171 | 170 | scope: "workspace", |
| 172 | − provider: "managed", | |
| 171 | + provider: "local-convex", | |
| 173 | 172 | secretJson: { token: "workspace-token" }, |
| 174 | 173 | }); |
| 175 | 174 | |
| 176 | − expect(workspaceCredential.provider).toBe("managed"); | |
| 175 | + expect(workspaceCredential.provider).toBe("local-convex"); | |
| 177 | 176 | |
| 178 | 177 | const actorCredential = await t.mutation(internal.database.upsertCredential, { |
| 179 | 178 | workspaceId: wsId, |
| @@ -192,7 +191,7 @@ test("credentials persist provider and resolve by scope", async () => { · 12 unmodified lines | ||
| 192 | 191 | sourceKey: "openapi:github", |
| 193 | 192 | scope: "workspace", |
| 194 | 193 | }); |
| 195 | − expect(resolvedWorkspace?.provider).toBe("managed"); | |
| 194 | + expect(resolvedWorkspace?.provider).toBe("local-convex"); | |
| 196 | 195 | |
| 197 | 196 | const resolvedActor = await t.query(internal.database.resolveCredential, { |
| 198 | 197 | workspaceId: wsId, |
| @@ -203,7 +202,7 @@ test("credentials persist provider and resolve by scope", async () => { · 4 unmodified lines | ||
| 203 | 202 | expect(resolvedActor?.provider).toBe("workos-vault"); |
| 204 | 203 | }); |
| 205 | 204 | |
| 206 | −test("upsertCredential defaults provider to managed", async () => { | |
| 205 | +test("upsertCredential defaults provider to local provider", async () => { | |
| 207 | 206 | const t = setup(); |
| 208 | 207 | const wsId = await seedWorkspace(t, "ws_default_provider"); |
| 209 | 208 | |
| @@ -214,5 +213,50 @@ test("upsertCredential defaults provider to managed", async () => { · 4 unmodified lines | ||
| 214 | 213 | secretJson: { token: "sk_test_123" }, |
| 215 | 214 | }); |
| 216 | 215 | |
| 217 | − expect(credential.provider).toBe("managed"); | |
| 216 | + expect(credential.provider).toBe("local-convex"); | |
| 217 | +}); | |
| 218 | + | |
| 219 | +test("credentials can link one connection to multiple sources", async () => { | |
| 220 | + const t = setup(); | |
| 221 | + const wsId = await seedWorkspace(t, "ws_linked_connection"); | |
| 222 | + | |
| 223 | + const primary = await t.mutation(internal.database.upsertCredential, { | |
| 224 | + workspaceId: wsId, | |
| 225 | + sourceKey: "source:github", | |
| 226 | + scope: "workspace", | |
| 227 | + secretJson: { token: "token_v1" }, | |
| 228 | + }); | |
| 229 | + | |
| 230 | + await t.mutation(internal.database.upsertCredential, { | |
| 231 | + id: primary.id, | |
| 232 | + workspaceId: wsId, | |
| 233 | + sourceKey: "source:stripe", | |
| 234 | + scope: "workspace", | |
| 235 | + secretJson: {}, | |
| 236 | + overridesJson: { headers: { "x-tenant-id": "acme" } }, | |
| 237 | + }); | |
| 238 | + | |
| 239 | + const linked = await t.query(internal.database.resolveCredential, { | |
| 240 | + workspaceId: wsId, | |
| 241 | + sourceKey: "source:stripe", | |
| 242 | + scope: "workspace", | |
| 243 | + }); | |
| 244 | + expect(linked?.id).toBe(primary.id); | |
| 245 | + expect(linked?.secretJson).toEqual({ token: "token_v1" }); | |
| 246 | + expect(linked?.overridesJson).toEqual({ headers: { "x-tenant-id": "acme" } }); | |
| 247 | + | |
| 248 | + await t.mutation(internal.database.upsertCredential, { | |
| 249 | + id: primary.id, | |
| 250 | + workspaceId: wsId, | |
| 251 | + sourceKey: "source:github", | |
| 252 | + scope: "workspace", | |
| 253 | + secretJson: { token: "token_v2" }, | |
| 254 | + }); | |
| 255 | + | |
| 256 | + const relinked = await t.query(internal.database.resolveCredential, { | |
| 257 | + workspaceId: wsId, | |
| 258 | + sourceKey: "source:stripe", | |
| 259 | + scope: "workspace", | |
| 260 | + }); | |
| 261 | + expect(relinked?.secretJson).toEqual({ token: "token_v2" }); | |
| 218 | 262 | }); |
| @@ -2,6 +2,7 @@ import { v } from "convex/values"; · 1 unmodified line | ||
| 2 | 2 | import type { Doc } from "./_generated/dataModel"; |
| 3 | 3 | import { type MutationCtx, type QueryCtx } from "./_generated/server"; |
| 4 | 4 | import { internalMutation, internalQuery } from "./_generated/server"; |
| 5 | +import { slugify } from "../core/src/identity"; | |
| 5 | 6 | import { ensureUniqueSlug } from "../core/src/slug"; |
| 6 | 7 | import type { TaskStatus } from "../core/src/types"; |
| 7 | 8 | |
| @@ -24,21 +25,13 @@ const terminalToolCallStatusValidator = v.union( · 16 unmodified lines | ||
| 24 | 25 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 25 | 26 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); |
| 26 | 27 | const credentialProviderValidator = v.union( |
| 27 | − v.literal("managed"), | |
| 28 | + v.literal("local-convex"), | |
| 28 | 29 | v.literal("workos-vault"), |
| 29 | 30 | ); |
| 30 | 31 | const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); |
| 31 | 32 | |
| 32 | −function slugify(input: string): string { | |
| 33 | − const slug = input | |
| 34 | − .toLowerCase() | |
| 35 | − .replace(/[^a-z0-9]+/g, "-") | |
| 36 | − .replace(/^-+|-+$/g, ""); | |
| 37 | − return slug.length > 0 ? slug : "workspace"; | |
| 38 | −} | |
| 39 | − | |
| 40 | 33 | async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> { |
| 41 | − const baseSlug = slugify(baseName); | |
| 34 | + const baseSlug = slugify(baseName, "workspace"); | |
| 42 | 35 | return await ensureUniqueSlug(baseSlug, async (candidate) => { |
| 43 | 36 | const collision = await ctx.db |
| 44 | 37 | .query("organizations") |
| @@ -96,6 +89,46 @@ function asRecord(value: unknown): Record<string, unknown> { · 51 unmodified lines | ||
| 96 | 89 | return {}; |
| 97 | 90 | } |
| 98 | 91 | |
| 92 | +function stableStringify(value: unknown): string { | |
| 93 | + if (Array.isArray(value)) { | |
| 94 | + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; | |
| 95 | + } | |
| 96 | + if (value && typeof value === "object") { | |
| 97 | + const record = value as Record<string, unknown>; | |
| 98 | + const keys = Object.keys(record).sort(); | |
| 99 | + return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`; | |
| 100 | + } | |
| 101 | + return JSON.stringify(value); | |
| 102 | +} | |
| 103 | + | |
| 104 | +function normalizeSourceAuthFingerprint(value: unknown): string { | |
| 105 | + const auth = asRecord(value); | |
| 106 | + const type = typeof auth.type === "string" ? auth.type.trim() : "none"; | |
| 107 | + const mode = auth.mode === "actor" ? "actor" : "workspace"; | |
| 108 | + const header = typeof auth.header === "string" ? auth.header.trim().toLowerCase() : ""; | |
| 109 | + return stableStringify({ | |
| 110 | + type: type || "none", | |
| 111 | + mode, | |
| 112 | + ...(header ? { header } : {}), | |
| 113 | + }); | |
| 114 | +} | |
| 115 | + | |
| 116 | +function computeSourceSpecHash(type: "mcp" | "openapi" | "graphql", config: Record<string, unknown>): string { | |
| 117 | + if (type === "openapi") { | |
| 118 | + const spec = config.spec; | |
| 119 | + if (typeof spec === "string") { | |
| 120 | + return `openapi:${spec.trim()}`; | |
| 121 | + } | |
| 122 | + return `openapi:${stableStringify(spec)}`; | |
| 123 | + } | |
| 124 | + if (type === "graphql") { | |
| 125 | + const endpoint = typeof config.endpoint === "string" ? config.endpoint.trim() : ""; | |
| 126 | + return `graphql:${endpoint}`; | |
| 127 | + } | |
| 128 | + const url = typeof config.url === "string" ? config.url.trim() : ""; | |
| 129 | + return `mcp:${url}`; | |
| 130 | +} | |
| 131 | + | |
| 99 | 132 | function mapTask(doc: Doc<"tasks">) { |
| 100 | 133 | return { |
| 101 | 134 | id: doc.taskId, |
| @@ -112,8 +145,7 @@ function mapTask(doc: Doc<"tasks">) { · 10 unmodified lines | ||
| 112 | 145 | startedAt: doc.startedAt, |
| 113 | 146 | completedAt: doc.completedAt, |
| 114 | 147 | error: doc.error, |
| 115 | − stdout: doc.stdout, | |
| 116 | − stderr: doc.stderr, | |
| 148 | + result: doc.result, | |
| 117 | 149 | exitCode: doc.exitCode, |
| 118 | 150 | }; |
| 119 | 151 | } |
| @@ -138,10 +170,8 @@ function mapToolCall(doc: Doc<"toolCalls">) { · 18 unmodified lines | ||
| 138 | 170 | callId: doc.callId, |
| 139 | 171 | workspaceId: doc.workspaceId, |
| 140 | 172 | toolPath: doc.toolPath, |
| 141 | − input: doc.input, | |
| 142 | 173 | status: doc.status, |
| 143 | 174 | approvalId: doc.approvalId, |
| 144 | − output: doc.output, | |
| 145 | 175 | error: doc.error, |
| 146 | 176 | createdAt: doc.createdAt, |
| 147 | 177 | updatedAt: doc.updatedAt, |
| @@ -166,12 +196,15 @@ function mapPolicy(doc: Doc<"accessPolicies">) { · 18 unmodified lines | ||
| 166 | 196 | function mapCredential(doc: Doc<"sourceCredentials">) { |
| 167 | 197 | return { |
| 168 | 198 | id: doc.credentialId, |
| 199 | + bindingId: doc.bindingId, | |
| 169 | 200 | workspaceId: doc.workspaceId, |
| 170 | 201 | sourceKey: doc.sourceKey, |
| 171 | 202 | scope: doc.scope, |
| 172 | 203 | actorId: doc.actorId || undefined, |
| 173 | − provider: doc.provider ?? "managed", | |
| 204 | + provider: doc.provider, | |
| 174 | 205 | secretJson: asRecord(doc.secretJson), |
| 206 | + overridesJson: asRecord(doc.overridesJson), | |
| 207 | + boundAuthFingerprint: doc.boundAuthFingerprint, | |
| 175 | 208 | createdAt: doc.createdAt, |
| 176 | 209 | updatedAt: doc.updatedAt, |
| 177 | 210 | }; |
| @@ -184,6 +217,8 @@ function mapSource(doc: Doc<"toolSources">) { · 6 unmodified lines | ||
| 184 | 217 | name: doc.name, |
| 185 | 218 | type: doc.type, |
| 186 | 219 | config: asRecord(doc.config), |
| 220 | + specHash: doc.specHash, | |
| 221 | + authFingerprint: doc.authFingerprint, | |
| 187 | 222 | enabled: Boolean(doc.enabled), |
| 188 | 223 | createdAt: doc.createdAt, |
| 189 | 224 | updatedAt: doc.updatedAt, |
| @@ -468,8 +503,7 @@ export const markTaskFinished = internalMutation({ · 278 unmodified lines | ||
| 468 | 503 | args: { |
| 469 | 504 | taskId: v.string(), |
| 470 | 505 | status: completedTaskStatusValidator, |
| 471 | − stdout: v.string(), | |
| 472 | − stderr: v.string(), | |
| 506 | + result: v.optional(v.any()), | |
| 473 | 507 | exitCode: v.optional(v.number()), |
| 474 | 508 | error: v.optional(v.string()), |
| 475 | 509 | }, |
| @@ -486,8 +520,7 @@ export const markTaskFinished = internalMutation({ · 10 unmodified lines | ||
| 486 | 520 | const now = Date.now(); |
| 487 | 521 | await ctx.db.patch(doc._id, { |
| 488 | 522 | status: args.status, |
| 489 | − stdout: args.stdout, | |
| 490 | − stderr: args.stderr, | |
| 523 | + result: args.result, | |
| 491 | 524 | exitCode: args.exitCode, |
| 492 | 525 | error: args.error, |
| 493 | 526 | completedAt: now, |
| @@ -655,7 +688,6 @@ export const upsertToolCallRequested = internalMutation({ · 161 unmodified lines | ||
| 655 | 688 | callId: v.string(), |
| 656 | 689 | workspaceId: v.id("workspaces"), |
| 657 | 690 | toolPath: v.string(), |
| 658 | − input: v.optional(v.any()), | |
| 659 | 691 | }, |
| 660 | 692 | handler: async (ctx, args) => { |
| 661 | 693 | const existing = await getToolCallDoc(ctx, args.taskId, args.callId); |
| @@ -669,7 +701,6 @@ export const upsertToolCallRequested = internalMutation({ · 7 unmodified lines | ||
| 669 | 701 | callId: args.callId, |
| 670 | 702 | workspaceId: args.workspaceId, |
| 671 | 703 | toolPath: args.toolPath, |
| 672 | − input: args.input ?? {}, | |
| 673 | 704 | status: "requested", |
| 674 | 705 | createdAt: now, |
| 675 | 706 | updatedAt: now, |
| @@ -730,7 +761,6 @@ export const finishToolCall = internalMutation({ · 54 unmodified lines | ||
| 730 | 761 | taskId: v.string(), |
| 731 | 762 | callId: v.string(), |
| 732 | 763 | status: terminalToolCallStatusValidator, |
| 733 | − output: v.optional(v.any()), | |
| 734 | 764 | error: v.optional(v.string()), |
| 735 | 765 | }, |
| 736 | 766 | handler: async (ctx, args) => { |
| @@ -746,7 +776,6 @@ export const finishToolCall = internalMutation({ · 9 unmodified lines | ||
| 746 | 776 | const now = Date.now(); |
| 747 | 777 | await ctx.db.patch(doc._id, { |
| 748 | 778 | status: args.status, |
| 749 | − output: args.output, | |
| 750 | 779 | error: args.error, |
| 751 | 780 | updatedAt: now, |
| 752 | 781 | completedAt: now, |
| @@ -924,6 +953,33 @@ export const listAccessPolicies = internalQuery({ · 171 unmodified lines | ||
| 924 | 953 | }, |
| 925 | 954 | }); |
| 926 | 955 | |
| 956 | +async function computeBoundAuthFingerprint( | |
| 957 | + ctx: Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">, | |
| 958 | + workspaceId: Doc<"workspaces">["_id"], | |
| 959 | + sourceKey: string, | |
| 960 | +): Promise<string> { | |
| 961 | + const prefix = "source:"; | |
| 962 | + if (!sourceKey.startsWith(prefix)) { | |
| 963 | + return normalizeSourceAuthFingerprint({ type: "none" }); | |
| 964 | + } | |
| 965 | + | |
| 966 | + const sourceId = sourceKey.slice(prefix.length).trim(); | |
| 967 | + if (!sourceId) { | |
| 968 | + return normalizeSourceAuthFingerprint({ type: "none" }); | |
| 969 | + } | |
| 970 | + | |
| 971 | + const source = await ctx.db | |
| 972 | + .query("toolSources") | |
| 973 | + .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId)) | |
| 974 | + .unique(); | |
| 975 | + | |
| 976 | + if (!source || source.workspaceId !== workspaceId) { | |
| 977 | + return normalizeSourceAuthFingerprint({ type: "none" }); | |
| 978 | + } | |
| 979 | + | |
| 980 | + return normalizeSourceAuthFingerprint(asRecord(source.config).auth); | |
| 981 | +} | |
| 982 | + | |
| 927 | 983 | export const upsertCredential = internalMutation({ |
| 928 | 984 | args: { |
| 929 | 985 | id: v.optional(v.string()), |
| @@ -933,10 +989,13 @@ export const upsertCredential = internalMutation({ · 3 unmodified lines | ||
| 933 | 989 | actorId: v.optional(v.string()), |
| 934 | 990 | provider: v.optional(credentialProviderValidator), |
| 935 | 991 | secretJson: v.any(), |
| 992 | + overridesJson: v.optional(v.any()), | |
| 936 | 993 | }, |
| 937 | 994 | handler: async (ctx, args) => { |
| 938 | 995 | const now = Date.now(); |
| 939 | 996 | const actorId = args.scope === "actor" ? (args.actorId?.trim() || "") : ""; |
| 997 | + const submittedSecret = asRecord(args.secretJson); | |
| 998 | + const hasSubmittedSecret = Object.keys(submittedSecret).length > 0; | |
| 940 | 999 | |
| 941 | 1000 | const existing = await ctx.db |
| 942 | 1001 | .query("sourceCredentials") |
| @@ -949,23 +1008,71 @@ export const upsertCredential = internalMutation({ · 6 unmodified lines | ||
| 949 | 1008 | ) |
| 950 | 1009 | .unique(); |
| 951 | 1010 | |
| 952 | − const provider = args.provider ?? existing?.provider ?? "managed"; | |
| 1011 | + let requestedId = args.id?.trim() || ""; | |
| 1012 | + if (requestedId.startsWith("bind_")) { | |
| 1013 | + const binding = await ctx.db | |
| 1014 | + .query("sourceCredentials") | |
| 1015 | + .withIndex("by_binding_id", (q) => q.eq("bindingId", requestedId)) | |
| 1016 | + .unique(); | |
| 1017 | + if (binding && binding.workspaceId === args.workspaceId) { | |
| 1018 | + requestedId = binding.credentialId; | |
| 1019 | + } | |
| 1020 | + } | |
| 1021 | + | |
| 1022 | + const connectionId = requestedId || existing?.credentialId || `conn_${crypto.randomUUID()}`; | |
| 1023 | + | |
| 1024 | + const linkedRows = await ctx.db | |
| 1025 | + .query("sourceCredentials") | |
| 1026 | + .withIndex("by_workspace_credential", (q) => | |
| 1027 | + q.eq("workspaceId", args.workspaceId).eq("credentialId", connectionId), | |
| 1028 | + ) | |
| 1029 | + .collect(); | |
| 1030 | + const exemplar = linkedRows[0] ?? existing ?? null; | |
| 1031 | + | |
| 1032 | + const provider = args.provider ?? exemplar?.provider ?? "local-convex"; | |
| 1033 | + const fallbackSecret = asRecord(exemplar?.secretJson); | |
| 1034 | + const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret; | |
| 1035 | + if (Object.keys(finalSecret).length === 0) { | |
| 1036 | + throw new Error("Credential values are required"); | |
| 1037 | + } | |
| 1038 | + | |
| 1039 | + const overridesJson = args.overridesJson === undefined | |
| 1040 | + ? asRecord(existing?.overridesJson) | |
| 1041 | + : asRecord(args.overridesJson); | |
| 1042 | + | |
| 1043 | + const boundAuthFingerprint = await computeBoundAuthFingerprint(ctx, args.workspaceId, args.sourceKey); | |
| 1044 | + | |
| 1045 | + if (linkedRows.length > 0 && (hasSubmittedSecret || args.provider)) { | |
| 1046 | + await Promise.all(linkedRows.map(async (row) => { | |
| 1047 | + await ctx.db.patch(row._id, { | |
| 1048 | + provider, | |
| 1049 | + secretJson: finalSecret, | |
| 1050 | + updatedAt: now, | |
| 1051 | + }); | |
| 1052 | + })); | |
| 1053 | + } | |
| 953 | 1054 | |
| 954 | 1055 | if (existing) { |
| 955 | 1056 | await ctx.db.patch(existing._id, { |
| 1057 | + credentialId: connectionId, | |
| 956 | 1058 | provider, |
| 957 | − secretJson: asRecord(args.secretJson), | |
| 1059 | + secretJson: finalSecret, | |
| 1060 | + overridesJson, | |
| 1061 | + boundAuthFingerprint, | |
| 958 | 1062 | updatedAt: now, |
| 959 | 1063 | }); |
| 960 | 1064 | } else { |
| 961 | 1065 | await ctx.db.insert("sourceCredentials", { |
| 962 | − credentialId: args.id ?? `cred_${crypto.randomUUID()}`, | |
| 1066 | + bindingId: `bind_${crypto.randomUUID()}`, | |
| 1067 | + credentialId: connectionId, | |
| 963 | 1068 | workspaceId: args.workspaceId, |
| 964 | 1069 | sourceKey: args.sourceKey, |
| 965 | 1070 | scope: args.scope, |
| 966 | 1071 | actorId, |
| 967 | 1072 | provider, |
| 968 | − secretJson: asRecord(args.secretJson), | |
| 1073 | + secretJson: finalSecret, | |
| 1074 | + overridesJson, | |
| 1075 | + boundAuthFingerprint, | |
| 969 | 1076 | createdAt: now, |
| 970 | 1077 | updatedAt: now, |
| 971 | 1078 | }); |
| @@ -1005,16 +1112,14 @@ export const listCredentials = internalQuery({ · 33 unmodified lines | ||
| 1005 | 1112 | export const listCredentialProviders = internalQuery({ |
| 1006 | 1113 | args: {}, |
| 1007 | 1114 | handler: async () => { |
| 1115 | + const workosEnabled = Boolean(process.env.WORKOS_API_KEY?.trim()); | |
| 1008 | 1116 | return [ |
| 1009 | 1117 | { |
| 1010 | − id: "managed", | |
| 1011 | − label: "Managed", | |
| 1012 | − description: "Store credential payload in Executor's sourceCredentials table.", | |
| 1013 | − }, | |
| 1014 | − { | |
| 1015 | − id: "workos-vault", | |
| 1016 | − label: "Encrypted", | |
| 1017 | − description: "Store credential payload in encrypted external storage.", | |
| 1118 | + id: workosEnabled ? "workos-vault" : "local-convex", | |
| 1119 | + label: workosEnabled ? "Encrypted" : "Local", | |
| 1120 | + description: workosEnabled | |
| 1121 | + ? "Secrets are stored in WorkOS Vault." | |
| 1122 | + : "Secrets are stored locally in Convex on this machine.", | |
| 1018 | 1123 | }, |
| 1019 | 1124 | ] as const; |
| 1020 | 1125 | }, |
| @@ -1075,6 +1180,9 @@ export const upsertToolSource = internalMutation({ · 54 unmodified lines | ||
| 1075 | 1180 | handler: async (ctx, args) => { |
| 1076 | 1181 | const now = Date.now(); |
| 1077 | 1182 | const sourceId = args.id ?? `src_${crypto.randomUUID()}`; |
| 1183 | + const config = asRecord(args.config); | |
| 1184 | + const specHash = computeSourceSpecHash(args.type, config); | |
| 1185 | + const authFingerprint = normalizeSourceAuthFingerprint(config.auth); | |
| 1078 | 1186 | const [existing, conflict] = await Promise.all([ |
| 1079 | 1187 | ctx.db |
| 1080 | 1188 | .query("toolSources") |
| @@ -1095,7 +1203,9 @@ export const upsertToolSource = internalMutation({ · 14 unmodified lines | ||
| 1095 | 1203 | workspaceId: args.workspaceId, |
| 1096 | 1204 | name: args.name, |
| 1097 | 1205 | type: args.type, |
| 1098 | − config: asRecord(args.config), | |
| 1206 | + config, | |
| 1207 | + specHash, | |
| 1208 | + authFingerprint, | |
| 1099 | 1209 | enabled: args.enabled !== false, |
| 1100 | 1210 | updatedAt: now, |
| 1101 | 1211 | }); |
| @@ -1105,7 +1215,9 @@ export const upsertToolSource = internalMutation({ · 3 unmodified lines | ||
| 1105 | 1215 | workspaceId: args.workspaceId, |
| 1106 | 1216 | name: args.name, |
| 1107 | 1217 | type: args.type, |
| 1108 | − config: asRecord(args.config), | |
| 1218 | + config, | |
| 1219 | + specHash, | |
| 1220 | + authFingerprint, | |
| 1109 | 1221 | enabled: args.enabled !== false, |
| 1110 | 1222 | createdAt: now, |
| 1111 | 1223 | updatedAt: now, |
| @@ -1147,6 +1259,18 @@ export const deleteToolSource = internalMutation({ · 35 unmodified lines | ||
| 1147 | 1259 | return false; |
| 1148 | 1260 | } |
| 1149 | 1261 | |
| 1262 | + const sourceKey = `source:${args.sourceId}`; | |
| 1263 | + const bindings = await ctx.db | |
| 1264 | + .query("sourceCredentials") | |
| 1265 | + .withIndex("by_workspace_source_scope_actor", (q) => | |
| 1266 | + q.eq("workspaceId", args.workspaceId).eq("sourceKey", sourceKey), | |
| 1267 | + ) | |
| 1268 | + .collect(); | |
| 1269 | + | |
| 1270 | + for (const binding of bindings) { | |
| 1271 | + await ctx.db.delete(binding._id); | |
| 1272 | + } | |
| 1273 | + | |
| 1150 | 1274 | await ctx.db.delete(doc._id); |
| 1151 | 1275 | return true; |
| 1152 | 1276 | }, |
| @@ -1320,3 +1444,88 @@ export const getAnonymousOauthClient = internalQuery({ · 167 unmodified lines | ||
| 1320 | 1444 | }; |
| 1321 | 1445 | }, |
| 1322 | 1446 | }); |
| 1447 | + | |
| 1448 | +// ── Anonymous OAuth Authorization Codes ────────────────────────────────────── | |
| 1449 | + | |
| 1450 | +export const storeAnonymousOauthAuthorizationCode = internalMutation({ | |
| 1451 | + args: { | |
| 1452 | + code: v.string(), | |
| 1453 | + clientId: v.string(), | |
| 1454 | + redirectUri: v.string(), | |
| 1455 | + codeChallenge: v.string(), | |
| 1456 | + codeChallengeMethod: v.string(), | |
| 1457 | + actorId: v.string(), | |
| 1458 | + tokenClaims: v.optional(v.any()), | |
| 1459 | + expiresAt: v.number(), | |
| 1460 | + createdAt: v.number(), | |
| 1461 | + }, | |
| 1462 | + handler: async (ctx, args) => { | |
| 1463 | + await ctx.db.insert("anonymousOauthCodes", { | |
| 1464 | + code: args.code, | |
| 1465 | + clientId: args.clientId, | |
| 1466 | + redirectUri: args.redirectUri, | |
| 1467 | + codeChallenge: args.codeChallenge, | |
| 1468 | + codeChallengeMethod: args.codeChallengeMethod, | |
| 1469 | + actorId: args.actorId, | |
| 1470 | + tokenClaims: args.tokenClaims, | |
| 1471 | + expiresAt: args.expiresAt, | |
| 1472 | + createdAt: args.createdAt, | |
| 1473 | + }); | |
| 1474 | + }, | |
| 1475 | +}); | |
| 1476 | + | |
| 1477 | +export const consumeAnonymousOauthAuthorizationCode = internalMutation({ | |
| 1478 | + args: { | |
| 1479 | + code: v.string(), | |
| 1480 | + }, | |
| 1481 | + handler: async (ctx, args) => { | |
| 1482 | + const doc = await ctx.db | |
| 1483 | + .query("anonymousOauthCodes") | |
| 1484 | + .withIndex("by_code", (q) => q.eq("code", args.code)) | |
| 1485 | + .unique(); | |
| 1486 | + | |
| 1487 | + if (!doc) { | |
| 1488 | + return null; | |
| 1489 | + } | |
| 1490 | + | |
| 1491 | + await ctx.db.delete(doc._id); | |
| 1492 | + | |
| 1493 | + return { | |
| 1494 | + code: doc.code, | |
| 1495 | + clientId: doc.clientId, | |
| 1496 | + redirectUri: doc.redirectUri, | |
| 1497 | + codeChallenge: doc.codeChallenge, | |
| 1498 | + codeChallengeMethod: doc.codeChallengeMethod, | |
| 1499 | + actorId: doc.actorId, | |
| 1500 | + tokenClaims: doc.tokenClaims, | |
| 1501 | + expiresAt: doc.expiresAt, | |
| 1502 | + createdAt: doc.createdAt, | |
| 1503 | + }; | |
| 1504 | + }, | |
| 1505 | +}); | |
| 1506 | + | |
| 1507 | +export const purgeExpiredAnonymousOauthAuthorizationCodes = internalMutation({ | |
| 1508 | + args: { | |
| 1509 | + now: v.number(), | |
| 1510 | + }, | |
| 1511 | + handler: async (ctx, args) => { | |
| 1512 | + const expired = await ctx.db | |
| 1513 | + .query("anonymousOauthCodes") | |
| 1514 | + .withIndex("by_expires_at", (q) => q.lt("expiresAt", args.now)) | |
| 1515 | + .collect(); | |
| 1516 | + | |
| 1517 | + for (const doc of expired) { | |
| 1518 | + await ctx.db.delete(doc._id); | |
| 1519 | + } | |
| 1520 | + | |
| 1521 | + return { purged: expired.length }; | |
| 1522 | + }, | |
| 1523 | +}); | |
| 1524 | + | |
| 1525 | +export const countAnonymousOauthAuthorizationCodes = internalQuery({ | |
| 1526 | + args: {}, | |
| 1527 | + handler: async (ctx) => { | |
| 1528 | + const docs = await ctx.db.query("anonymousOauthCodes").collect(); | |
| 1529 | + return { count: docs.length }; | |
| 1530 | + }, | |
| 1531 | +}); | |
| @@ -222,35 +222,11 @@ export const resolveApprovalInternal = internalMutation({ · 221 unmodified lines | ||
| 222 | 222 | }, |
| 223 | 223 | }); |
| 224 | 224 | |
| 225 | −export const appendRuntimeOutput = internalMutation({ | |
| 226 | − args: { | |
| 227 | − runId: v.string(), | |
| 228 | − stream: v.union(v.literal("stdout"), v.literal("stderr")), | |
| 229 | − line: v.string(), | |
| 230 | − timestamp: v.optional(v.number()), | |
| 231 | − }, | |
| 232 | − handler: async (ctx, args) => { | |
| 233 | − await ctx.runMutation(internal.database.createTaskEvent, { | |
| 234 | − taskId: args.runId, | |
| 235 | − eventName: "task", | |
| 236 | − type: args.stream === "stdout" ? "task.stdout" : "task.stderr", | |
| 237 | − payload: { | |
| 238 | − taskId: args.runId, | |
| 239 | − line: args.line, | |
| 240 | − timestamp: args.timestamp ?? Date.now(), | |
| 241 | − }, | |
| 242 | − }); | |
| 243 | − | |
| 244 | − return { ok: true as const }; | |
| 245 | − }, | |
| 246 | −}); | |
| 247 | − | |
| 248 | 225 | export const completeRuntimeRun = internalMutation({ |
| 249 | 226 | args: { |
| 250 | 227 | runId: v.string(), |
| 251 | 228 | status: v.union(v.literal("completed"), v.literal("failed"), v.literal("timed_out"), v.literal("denied")), |
| 252 | − stdout: v.optional(v.string()), | |
| 253 | − stderr: v.optional(v.string()), | |
| 229 | + result: v.optional(v.any()), | |
| 254 | 230 | exitCode: v.optional(v.number()), |
| 255 | 231 | error: v.optional(v.string()), |
| 256 | 232 | durationMs: v.optional(v.number()), |
| @@ -265,11 +241,10 @@ export const completeRuntimeRun = internalMutation({ · 8 unmodified lines | ||
| 265 | 241 | return { ok: true as const, alreadyFinal: true as const, task }; |
| 266 | 242 | } |
| 267 | 243 | |
| 268 | − const finished = await ctx.runMutation(internal.database.markTaskFinished, { | |
| 244 | + const finished = await ctx.runMutation(internal.database.markTaskFinished as any, { | |
| 269 | 245 | taskId: args.runId, |
| 270 | 246 | status: args.status, |
| 271 | − stdout: args.stdout ?? "", | |
| 272 | − stderr: args.stderr ?? "", | |
| 247 | + result: args.result, | |
| 273 | 248 | exitCode: args.exitCode, |
| 274 | 249 | error: args.error, |
| 275 | 250 | }); |
| @@ -1,1062 +1,22 @@ | ||
| 1 | 1 | "use node"; |
| 2 | 2 | |
| 3 | 3 | import { v } from "convex/values"; |
| 4 | −import { internal } from "./_generated/api"; | |
| 5 | −import type { Id } from "./_generated/dataModel"; | |
| 6 | 4 | import { action, internalAction } from "./_generated/server"; |
| 7 | −import type { ActionCtx } from "./_generated/server"; | |
| 8 | −import { InProcessExecutionAdapter } from "../core/src/adapters/in-process-execution-adapter"; | |
| 9 | −import { resolveCredentialPayload } from "../core/src/credential-providers"; | |
| 10 | −import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../core/src/execution-constants"; | |
| 11 | −import { actorIdForAccount } from "../core/src/identity"; | |
| 12 | −import { dispatchCodeWithCloudflareWorkerLoader } from "../core/src/runtimes/cloudflare-worker-loader-runtime"; | |
| 13 | −import { | |
| 14 | − CLOUDFLARE_WORKER_LOADER_RUNTIME_ID, | |
| 15 | − isCloudflareWorkerLoaderConfigured, | |
| 16 | − isKnownRuntimeId, | |
| 17 | − LOCAL_BUN_RUNTIME_ID, | |
| 18 | −} from "../core/src/runtimes/runtime-catalog"; | |
| 19 | −import { runCodeWithAdapter } from "../core/src/runtimes/runtime-core"; | |
| 20 | −import { createDiscoverTool } from "../core/src/tool-discovery"; | |
| 21 | −import { | |
| 22 | − compileExternalToolSource, | |
| 23 | − compileOpenApiToolSourceFromPrepared, | |
| 24 | − materializeCompiledToolSource, | |
| 25 | − materializeWorkspaceSnapshot, | |
| 26 | − parseGraphqlOperationPaths, | |
| 27 | − prepareOpenApiSpec, | |
| 28 | − type PreparedOpenApiSpec, | |
| 29 | − type CompiledToolSourceArtifact, | |
| 30 | − type ExternalToolSourceConfig, | |
| 31 | − type McpToolSourceConfig, | |
| 32 | − type OpenApiToolSourceConfig, | |
| 33 | − type GraphqlToolSourceConfig, | |
| 34 | − type WorkspaceToolSnapshot, | |
| 35 | −} from "../core/src/tool-sources"; | |
| 36 | −import { DEFAULT_TOOLS } from "../core/src/tools"; | |
| 37 | 5 | import type { |
| 38 | − AccessPolicyRecord, | |
| 39 | − CredentialScope, | |
| 40 | − PolicyDecision, | |
| 41 | − ResolvedToolCredential, | |
| 42 | − TaskRecord, | |
| 43 | − ToolCallRequest, | |
| 44 | − ToolCallRecord, | |
| 45 | 6 | ToolCallResult, |
| 46 | − ToolCredentialSpec, | |
| 47 | − ToolDefinition, | |
| 48 | 7 | ToolDescriptor, |
| 49 | 8 | OpenApiSourceQuality, |
| 50 | − ToolSourceRecord, | |
| 51 | − ToolRunContext, | |
| 9 | + SourceAuthProfile, | |
| 52 | 10 | } from "../core/src/types"; |
| 53 | −import { asPayload, describeError } from "../core/src/utils"; | |
| 54 | − | |
| 55 | −function createApprovalId(): string { | |
| 56 | − return `approval_${crypto.randomUUID()}`; | |
| 57 | −} | |
| 58 | − | |
| 59 | −function matchesToolPath(pattern: string, toolPath: string): boolean { | |
| 60 | − const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); | |
| 61 | − const regex = new RegExp(`^${escaped}$`); | |
| 62 | − return regex.test(toolPath); | |
| 63 | −} | |
| 64 | − | |
| 65 | −function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientId?: string): number { | |
| 66 | − let score = 0; | |
| 67 | − if (policy.actorId && actorId && policy.actorId === actorId) score += 4; | |
| 68 | − if (policy.clientId && clientId && policy.clientId === clientId) score += 2; | |
| 69 | − score += Math.max(1, policy.toolPathPattern.replace(/\*/g, "").length); | |
| 70 | − score += policy.priority; | |
| 71 | − return score; | |
| 72 | −} | |
| 73 | − | |
| 74 | −function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { | |
| 75 | − const signatureVersion = "v15"; | |
| 76 | − const parts = sources | |
| 77 | − .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`) | |
| 78 | − .sort(); | |
| 79 | − return `${signatureVersion}|${workspaceId}|${parts.join(",")}`; | |
| 80 | −} | |
| 81 | − | |
| 82 | −function normalizeToolPathSegment(segment: string): string { | |
| 83 | − return segment.toLowerCase().replace(/[^a-z0-9]/g, ""); | |
| 84 | −} | |
| 85 | − | |
| 86 | −function normalizeToolPath(path: string): string { | |
| 87 | − return path | |
| 88 | − .split(".") | |
| 89 | − .filter(Boolean) | |
| 90 | − .map((segment) => normalizeToolPathSegment(segment)) | |
| 91 | − .join("."); | |
| 92 | −} | |
| 93 | − | |
| 94 | −function levenshteinDistance(a: string, b: string): number { | |
| 95 | − if (a === b) return 0; | |
| 96 | − if (a.length === 0) return b.length; | |
| 97 | − if (b.length === 0) return a.length; | |
| 98 | − | |
| 99 | − const prev = new Array<number>(b.length + 1); | |
| 100 | − const curr = new Array<number>(b.length + 1); | |
| 101 | − for (let j = 0; j <= b.length; j++) prev[j] = j; | |
| 102 | − | |
| 103 | − for (let i = 1; i <= a.length; i++) { | |
| 104 | − curr[0] = i; | |
| 105 | − for (let j = 1; j <= b.length; j++) { | |
| 106 | − const cost = a[i - 1] === b[j - 1] ? 0 : 1; | |
| 107 | − curr[j] = Math.min( | |
| 108 | − curr[j - 1] + 1, | |
| 109 | − prev[j] + 1, | |
| 110 | − prev[j - 1] + cost, | |
| 111 | − ); | |
| 112 | − } | |
| 113 | − for (let j = 0; j <= b.length; j++) prev[j] = curr[j]; | |
| 114 | − } | |
| 115 | − | |
| 116 | − return prev[b.length] ?? Math.max(a.length, b.length); | |
| 117 | −} | |
| 118 | − | |
| 119 | −function resolveAliasedToolPath( | |
| 120 | − requestedPath: string, | |
| 121 | − toolMap: Map<string, ToolDefinition>, | |
| 122 | −): string | null { | |
| 123 | − if (toolMap.has(requestedPath)) return requestedPath; | |
| 124 | − | |
| 125 | − const normalizedRequested = normalizeToolPath(requestedPath); | |
| 126 | − if (!normalizedRequested) return null; | |
| 127 | − | |
| 128 | − const matches: string[] = []; | |
| 129 | − for (const path of toolMap.keys()) { | |
| 130 | − if (normalizeToolPath(path) === normalizedRequested) { | |
| 131 | − matches.push(path); | |
| 132 | − } | |
| 133 | − } | |
| 134 | − | |
| 135 | − if (matches.length === 0) return null; | |
| 136 | − if (matches.length === 1) return matches[0]!; | |
| 137 | − | |
| 138 | − const requestedSegments = requestedPath.split(".").length; | |
| 139 | − const sameSegmentCount = matches.filter((path) => path.split(".").length === requestedSegments); | |
| 140 | − const pool = sameSegmentCount.length > 0 ? sameSegmentCount : matches; | |
| 141 | − return [...pool].sort((a, b) => a.length - b.length || a.localeCompare(b))[0] ?? null; | |
| 142 | −} | |
| 143 | − | |
| 144 | −function suggestToolPaths( | |
| 145 | − requestedPath: string, | |
| 146 | − toolMap: Map<string, ToolDefinition>, | |
| 147 | − limit = 3, | |
| 148 | −): string[] { | |
| 149 | − const normalizedRequested = normalizeToolPath(requestedPath); | |
| 150 | − const requestedSegments = normalizedRequested.split(".").filter(Boolean); | |
| 151 | − const requestedNamespace = requestedSegments[0] ?? ""; | |
| 152 | − | |
| 153 | − const scored = [...toolMap.keys()] | |
| 154 | − .map((path) => { | |
| 155 | − const normalizedCandidate = normalizeToolPath(path); | |
| 156 | − const candidateSegments = normalizedCandidate.split(".").filter(Boolean); | |
| 157 | − const candidateNamespace = candidateSegments[0] ?? ""; | |
| 158 | − | |
| 159 | − let score = -levenshteinDistance(normalizedRequested, normalizedCandidate); | |
| 160 | − | |
| 161 | − if (requestedNamespace && requestedNamespace === candidateNamespace) { | |
| 162 | − score += 6; | |
| 163 | − } | |
| 164 | − | |
| 165 | − if (normalizedCandidate.includes(normalizedRequested) || normalizedRequested.includes(normalizedCandidate)) { | |
| 166 | − score += 3; | |
| 167 | − } | |
| 168 | − | |
| 169 | − const sharedPrefix = Math.min(requestedSegments.length, candidateSegments.length); | |
| 170 | − let prefixMatches = 0; | |
| 171 | − for (let i = 0; i < sharedPrefix; i++) { | |
| 172 | − if (requestedSegments[i] !== candidateSegments[i]) break; | |
| 173 | − prefixMatches += 1; | |
| 174 | − } | |
| 175 | − score += prefixMatches * 2; | |
| 176 | − | |
| 177 | − return { path, score }; | |
| 178 | − }) | |
| 179 | − .sort((a, b) => b.score - a.score) | |
| 180 | − .slice(0, limit) | |
| 181 | − .map((item) => item.path); | |
| 182 | − | |
| 183 | − return scored; | |
| 184 | −} | |
| 185 | − | |
| 186 | −function normalizeExternalToolSource(raw: { | |
| 187 | − id: string; | |
| 188 | − type: ToolSourceRecord["type"]; | |
| 189 | − name: string; | |
| 190 | − config: Record<string, unknown>; | |
| 191 | −}): ExternalToolSourceConfig { | |
| 192 | − const config = raw.config; | |
| 193 | − | |
| 194 | − if (raw.type === "mcp") { | |
| 195 | − if (typeof config.url !== "string" || config.url.trim().length === 0) { | |
| 196 | − throw new Error(`MCP source '${raw.name}' missing url`); | |
| 197 | − } | |
| 198 | − | |
| 199 | − if ( | |
| 200 | − config.transport !== undefined | |
| 201 | − && config.transport !== "sse" | |
| 202 | − && config.transport !== "streamable-http" | |
| 203 | − ) { | |
| 204 | − throw new Error(`MCP source '${raw.name}' has invalid transport`); | |
| 205 | − } | |
| 206 | − | |
| 207 | − if (config.queryParams !== undefined) { | |
| 208 | − const queryParams = config.queryParams; | |
| 209 | − if (!queryParams || typeof queryParams !== "object" || Array.isArray(queryParams)) { | |
| 210 | − throw new Error(`MCP source '${raw.name}' queryParams must be an object`); | |
| 211 | − } | |
| 212 | − | |
| 213 | − for (const value of Object.values(queryParams as Record<string, unknown>)) { | |
| 214 | − if (typeof value !== "string") { | |
| 215 | − throw new Error(`MCP source '${raw.name}' queryParams values must be strings`); | |
| 216 | − } | |
| 217 | − } | |
| 218 | − } | |
| 219 | − | |
| 220 | − const result: McpToolSourceConfig = { | |
| 221 | − type: "mcp", | |
| 222 | − name: raw.name, | |
| 223 | − sourceId: raw.id, | |
| 224 | − sourceKey: `source:${raw.id}`, | |
| 225 | − url: config.url, | |
| 226 | − transport: config.transport as McpToolSourceConfig["transport"], | |
| 227 | − queryParams: config.queryParams as McpToolSourceConfig["queryParams"], | |
| 228 | − defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"], | |
| 229 | − overrides: config.overrides as McpToolSourceConfig["overrides"], | |
| 230 | − }; | |
| 231 | − return result; | |
| 232 | − } | |
| 233 | − | |
| 234 | − if (raw.type === "graphql") { | |
| 235 | − if (typeof config.endpoint !== "string" || config.endpoint.trim().length === 0) { | |
| 236 | − throw new Error(`GraphQL source '${raw.name}' missing endpoint`); | |
| 237 | − } | |
| 238 | − | |
| 239 | − const result: GraphqlToolSourceConfig = { | |
| 240 | − type: "graphql", | |
| 241 | − name: raw.name, | |
| 242 | − sourceId: raw.id, | |
| 243 | − sourceKey: `source:${raw.id}`, | |
| 244 | − endpoint: config.endpoint, | |
| 245 | − schema: config.schema as GraphqlToolSourceConfig["schema"], | |
| 246 | − auth: config.auth as GraphqlToolSourceConfig["auth"], | |
| 247 | − defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"], | |
| 248 | − defaultMutationApproval: config.defaultMutationApproval as GraphqlToolSourceConfig["defaultMutationApproval"], | |
| 249 | − overrides: config.overrides as GraphqlToolSourceConfig["overrides"], | |
| 250 | − }; | |
| 251 | − return result; | |
| 252 | − } | |
| 253 | − | |
| 254 | − const spec = config.spec; | |
| 255 | − if (typeof spec !== "string" && (typeof spec !== "object" || spec === null)) { | |
| 256 | − throw new Error(`OpenAPI source '${raw.name}' missing spec`); | |
| 257 | − } | |
| 258 | − | |
| 259 | − const result: OpenApiToolSourceConfig = { | |
| 260 | − type: "openapi", | |
| 261 | − name: raw.name, | |
| 262 | − sourceId: raw.id, | |
| 263 | − sourceKey: `source:${raw.id}`, | |
| 264 | − spec: spec as OpenApiToolSourceConfig["spec"], | |
| 265 | − baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"], | |
| 266 | − auth: config.auth as OpenApiToolSourceConfig["auth"], | |
| 267 | − defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"], | |
| 268 | − defaultWriteApproval: config.defaultWriteApproval as OpenApiToolSourceConfig["defaultWriteApproval"], | |
| 269 | − overrides: config.overrides as OpenApiToolSourceConfig["overrides"], | |
| 270 | − }; | |
| 271 | − return result; | |
| 272 | −} | |
| 273 | − | |
| 274 | −const baseTools = new Map<string, ToolDefinition>(DEFAULT_TOOLS.map((tool) => [tool.path, tool])); | |
| 275 | −interface DtsStorageEntry { | |
| 276 | − sourceKey: string; | |
| 277 | − storageId: Id<"_storage">; | |
| 278 | −} | |
| 279 | − | |
| 280 | −const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; | |
| 281 | − | |
| 282 | −/** Cache version — bump when PreparedOpenApiSpec shape changes. */ | |
| 283 | −const OPENAPI_CACHE_VERSION = "v15"; | |
| 284 | − | |
| 285 | −async function publish( | |
| 286 | − ctx: ActionCtx, | |
| 287 | − taskId: string, | |
| 288 | − eventName: "task" | "approval", | |
| 289 | − type: string, | |
| 290 | − payload: Record<string, unknown>, | |
| 291 | −): Promise<void> { | |
| 292 | − await ctx.runMutation(internal.database.createTaskEvent, { | |
| 293 | − taskId, | |
| 294 | − eventName, | |
| 295 | − type, | |
| 296 | − payload, | |
| 297 | − }); | |
| 298 | −} | |
| 299 | − | |
| 300 | −/** | |
| 301 | − * Load a prepared OpenAPI spec, using Convex file storage as a persistent cache. | |
| 302 | − * | |
| 303 | − * Flow: | |
| 304 | − * 1. Check the `openApiSpecCache` table for a valid entry → fetch blob from storage. | |
| 305 | − * 2. On miss: prepare the spec from scratch, store the JSON blob, write the metadata row. | |
| 306 | − * | |
| 307 | − * No size limits — Convex file storage handles multi-MB specs without issue. | |
| 308 | − */ | |
| 309 | −async function loadCachedOpenApiSpec( | |
| 310 | − ctx: ActionCtx, | |
| 311 | − specUrl: string, | |
| 312 | − sourceName: string, | |
| 313 | −): Promise<PreparedOpenApiSpec> { | |
| 314 | − // 1. Persistent cache (Convex table + file storage) | |
| 315 | − try { | |
| 316 | − const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, { | |
| 317 | − specUrl, | |
| 318 | − version: OPENAPI_CACHE_VERSION, | |
| 319 | − maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS, | |
| 320 | − }); | |
| 321 | − | |
| 322 | − if (entry) { | |
| 323 | − const blob = await ctx.storage.get(entry.storageId); | |
| 324 | − if (blob) { | |
| 325 | − const json = await blob.text(); | |
| 326 | − return JSON.parse(json) as PreparedOpenApiSpec; | |
| 327 | − } | |
| 328 | − // Blob missing (deleted externally?) — fall through to re-prepare | |
| 329 | − } | |
| 330 | − } catch (error) { | |
| 331 | − // Cache read failed — log and fall through to fresh preparation | |
| 332 | − const message = error instanceof Error ? error.message : String(error); | |
| 333 | − console.warn(`[executor] OpenAPI cache read failed for '${sourceName}': ${message}`); | |
| 334 | − } | |
| 335 | − | |
| 336 | − // 2. Cache miss — prepare from scratch | |
| 337 | − const prepared = await prepareOpenApiSpec(specUrl, sourceName); | |
| 338 | − | |
| 339 | − // Store in file storage + metadata table (best-effort, don't block on failure) | |
| 340 | − try { | |
| 341 | − const json = JSON.stringify(prepared); | |
| 342 | − const blob = new Blob([json], { type: "application/json" }); | |
| 343 | − const storageId = await ctx.storage.store(blob); | |
| 344 | − await ctx.runMutation(internal.openApiSpecCache.putEntry, { | |
| 345 | − specUrl, | |
| 346 | − version: OPENAPI_CACHE_VERSION, | |
| 347 | − storageId, | |
| 348 | − sizeBytes: json.length, | |
| 349 | − }); | |
| 350 | − } catch (error) { | |
| 351 | − const message = error instanceof Error ? error.message : String(error); | |
| 352 | − console.warn(`[executor] OpenAPI cache write failed for '${sourceName}': ${message}`); | |
| 353 | − } | |
| 354 | − | |
| 355 | − return prepared; | |
| 356 | −} | |
| 357 | − | |
| 358 | −async function loadSourceArtifact( | |
| 359 | − ctx: ActionCtx, | |
| 360 | − source: ExternalToolSourceConfig, | |
| 361 | −): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> { | |
| 362 | − if (source.type === "openapi" && typeof source.spec === "string") { | |
| 363 | − try { | |
| 364 | − const prepared = await loadCachedOpenApiSpec(ctx, source.spec, source.name); | |
| 365 | − const artifact = compileOpenApiToolSourceFromPrepared(source, prepared); | |
| 366 | − const warnings = (prepared.warnings ?? []).map( | |
| 367 | − (warning) => `Source '${source.name}': ${warning}`, | |
| 368 | − ); | |
| 369 | − return { artifact, warnings }; | |
| 370 | − } catch (error) { | |
| 371 | − const message = error instanceof Error ? error.message : String(error); | |
| 372 | − return { | |
| 373 | − artifact: undefined, | |
| 374 | − warnings: [`Failed to load openapi source '${source.name}': ${message}`], | |
| 375 | − }; | |
| 376 | − } | |
| 377 | − } | |
| 378 | − | |
| 379 | − try { | |
| 380 | − const artifact = await compileExternalToolSource(source); | |
| 381 | − return { artifact, warnings: [] }; | |
| 382 | − } catch (error) { | |
| 383 | − const message = error instanceof Error ? error.message : String(error); | |
| 384 | − return { | |
| 385 | − artifact: undefined, | |
| 386 | − warnings: [`Failed to load ${source.type} source '${source.name}': ${message}`], | |
| 387 | − }; | |
| 388 | − } | |
| 389 | −} | |
| 390 | − | |
| 391 | −interface WorkspaceToolsResult { | |
| 392 | − tools: Map<string, ToolDefinition>; | |
| 393 | − warnings: string[]; | |
| 394 | − dtsStorageIds: DtsStorageEntry[]; | |
| 395 | −} | |
| 396 | − | |
| 397 | −async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspaces">): Promise<WorkspaceToolsResult> { | |
| 398 | − const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId })) | |
| 399 | − .filter((source: { enabled: boolean }) => source.enabled); | |
| 400 | − const signature = sourceSignature(workspaceId, sources); | |
| 401 | − | |
| 402 | − // ── Layer 1: Persistent workspace cache (Convex file storage) ────── | |
| 403 | − try { | |
| 404 | − const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, { | |
| 405 | − workspaceId, | |
| 406 | − signature, | |
| 407 | − }); | |
| 408 | − | |
| 409 | − if (cacheEntry) { | |
| 410 | − const blob = await ctx.storage.get(cacheEntry.storageId); | |
| 411 | − if (blob) { | |
| 412 | − const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot; | |
| 413 | − const restored = materializeWorkspaceSnapshot(snapshot); | |
| 414 | − | |
| 415 | − const merged = new Map<string, ToolDefinition>(); | |
| 416 | − for (const tool of baseTools.values()) { | |
| 417 | − if (tool.path === "discover") continue; | |
| 418 | − merged.set(tool.path, tool); | |
| 419 | − } | |
| 420 | − for (const tool of restored) { | |
| 421 | − if (tool.path === "discover") continue; | |
| 422 | − merged.set(tool.path, tool); | |
| 423 | − } | |
| 424 | − // Recreate discover tool (it captures the full tool list in a closure) | |
| 425 | − const discover = createDiscoverTool([...merged.values()]); | |
| 426 | − merged.set(discover.path, discover); | |
| 427 | − | |
| 428 | − const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[]; | |
| 429 | − | |
| 430 | − return { tools: merged, warnings: snapshot.warnings, dtsStorageIds }; | |
| 431 | − } | |
| 432 | − } | |
| 433 | − } catch (error) { | |
| 434 | − const msg = error instanceof Error ? error.message : String(error); | |
| 435 | − console.warn(`[executor] workspace tool cache read failed for '${workspaceId}': ${msg}`); | |
| 436 | − } | |
| 437 | − | |
| 438 | − // ── Layer 2: Full rebuild from sources ───────────────────────────── | |
| 439 | − const configs: ExternalToolSourceConfig[] = []; | |
| 440 | − const warnings: string[] = []; | |
| 441 | − for (const source of sources) { | |
| 442 | − try { | |
| 443 | − configs.push(normalizeExternalToolSource(source)); | |
| 444 | − } catch (error) { | |
| 445 | − const message = error instanceof Error ? error.message : String(error); | |
| 446 | − warnings.push(`Source '${source.name}': ${message}`); | |
| 447 | − } | |
| 448 | − } | |
| 449 | − | |
| 450 | − const loadedSources = await Promise.all(configs.map((config) => loadSourceArtifact(ctx, config))); | |
| 451 | − const externalArtifacts = loadedSources | |
| 452 | − .map((loaded) => loaded.artifact) | |
| 453 | − .filter((artifact): artifact is CompiledToolSourceArtifact => Boolean(artifact)); | |
| 454 | − const externalTools = externalArtifacts.flatMap((artifact) => materializeCompiledToolSource(artifact)); | |
| 455 | − warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings)); | |
| 456 | − | |
| 457 | − const merged = new Map<string, ToolDefinition>(); | |
| 458 | − for (const tool of baseTools.values()) { | |
| 459 | − if (tool.path === "discover") continue; | |
| 460 | − merged.set(tool.path, tool); | |
| 461 | − } | |
| 462 | − for (const tool of externalTools) { | |
| 463 | − merged.set(tool.path, tool); | |
| 464 | − } | |
| 465 | − | |
| 466 | − const discover = createDiscoverTool([...merged.values()]); | |
| 467 | − merged.set(discover.path, discover); | |
| 468 | − | |
| 469 | − // ── Write to persistent workspace cache (best-effort) ───────────── | |
| 470 | − let dtsStorageIds: DtsStorageEntry[] = []; | |
| 471 | − try { | |
| 472 | − const allTools = [...merged.values()]; | |
| 473 | − | |
| 474 | − // Extract and store .d.ts blobs per source (too large for action responses) | |
| 475 | − const seenDtsSources = new Set<string>(); | |
| 476 | − const dtsEntries: { sourceKey: string; content: string }[] = []; | |
| 477 | − for (const artifact of externalArtifacts) { | |
| 478 | − for (const tool of artifact.tools) { | |
| 479 | − if (tool.metadata?.sourceDts && tool.source && !seenDtsSources.has(tool.source)) { | |
| 480 | − seenDtsSources.add(tool.source); | |
| 481 | − dtsEntries.push({ sourceKey: tool.source, content: tool.metadata.sourceDts }); | |
| 482 | − } | |
| 483 | − } | |
| 484 | − } | |
| 485 | − | |
| 486 | − // Store .d.ts blobs in parallel | |
| 487 | − const storedDts = await Promise.all( | |
| 488 | − dtsEntries.map(async (entry) => { | |
| 489 | − const dtsBlob = new Blob([entry.content], { type: "text/plain" }); | |
| 490 | − const sid = await ctx.storage.store(dtsBlob); | |
| 491 | − return { sourceKey: entry.sourceKey, storageId: sid }; | |
| 492 | − }), | |
| 493 | − ); | |
| 494 | − dtsStorageIds = storedDts; | |
| 495 | − | |
| 496 | − // Strip sourceDts from artifacts (it's stored separately) | |
| 497 | − const sanitizedArtifacts: CompiledToolSourceArtifact[] = externalArtifacts.map((artifact) => ({ | |
| 498 | − ...artifact, | |
| 499 | − tools: artifact.tools.map((tool) => { | |
| 500 | − if (!tool.metadata?.sourceDts) return tool; | |
| 501 | − const metadata = { ...tool.metadata }; | |
| 502 | − delete (metadata as Record<string, unknown>).sourceDts; | |
| 503 | − return { ...tool, metadata }; | |
| 504 | − }), | |
| 505 | − })); | |
| 506 | − | |
| 507 | − const snapshot: WorkspaceToolSnapshot = { | |
| 508 | − version: "v2", | |
| 509 | − externalArtifacts: sanitizedArtifacts, | |
| 510 | − warnings, | |
| 511 | − }; | |
| 512 | − | |
| 513 | − const json = JSON.stringify(snapshot); | |
| 514 | − const blob = new Blob([json], { type: "application/json" }); | |
| 515 | − const storageId = await ctx.storage.store(blob); | |
| 516 | − await ctx.runMutation(internal.workspaceToolCache.putEntry, { | |
| 517 | − workspaceId, | |
| 518 | − signature, | |
| 519 | − storageId, | |
| 520 | − dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })), | |
| 521 | − toolCount: allTools.length, | |
| 522 | − sizeBytes: json.length, | |
| 523 | − }); | |
| 524 | − } catch (error) { | |
| 525 | − const msg = error instanceof Error ? error.message : String(error); | |
| 526 | − console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`); | |
| 527 | − } | |
| 528 | − | |
| 529 | − return { tools: merged, warnings, dtsStorageIds }; | |
| 530 | −} | |
| 531 | − | |
| 532 | −function getDecisionForContext( | |
| 533 | − tool: ToolDefinition, | |
| 534 | − context: { workspaceId: string; actorId?: string; clientId?: string }, | |
| 535 | − policies: AccessPolicyRecord[], | |
| 536 | −): PolicyDecision { | |
| 537 | − // `discover` is always available so models can find allowed tools dynamically. | |
| 538 | − // The discover implementation still filters returned results via context.isToolAllowed. | |
| 539 | − if (tool.path === "discover") { | |
| 540 | − return "allow"; | |
| 541 | − } | |
| 542 | − | |
| 543 | − const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow"; | |
| 544 | − const candidates = policies | |
| 545 | − .filter((policy) => { | |
| 546 | − if (policy.actorId && policy.actorId !== context.actorId) return false; | |
| 547 | − if (policy.clientId && policy.clientId !== context.clientId) return false; | |
| 548 | − return matchesToolPath(policy.toolPathPattern, tool.path); | |
| 549 | − }) | |
| 550 | − .sort( | |
| 551 | − (a, b) => | |
| 552 | − policySpecificity(b, context.actorId, context.clientId) | |
| 553 | − - policySpecificity(a, context.actorId, context.clientId), | |
| 554 | − ); | |
| 555 | − | |
| 556 | − return candidates[0]?.decision ?? defaultDecision; | |
| 557 | −} | |
| 558 | − | |
| 559 | −function getToolDecision(task: TaskRecord, tool: ToolDefinition, policies: AccessPolicyRecord[]): PolicyDecision { | |
| 560 | − return getDecisionForContext( | |
| 561 | − tool, | |
| 562 | − { | |
| 563 | − workspaceId: task.workspaceId, | |
| 564 | − actorId: task.actorId, | |
| 565 | − clientId: task.clientId, | |
| 566 | − }, | |
| 567 | − policies, | |
| 568 | − ); | |
| 569 | −} | |
| 570 | − | |
| 571 | −function toToolDescriptor(tool: ToolDefinition, approval: "auto" | "required"): ToolDescriptor { | |
| 572 | − return { | |
| 573 | − path: tool.path, | |
| 574 | − description: tool.description, | |
| 575 | − approval, | |
| 576 | − source: tool.source, | |
| 577 | − argsType: tool.metadata?.displayArgsType ?? tool.metadata?.argsType, | |
| 578 | − returnsType: tool.metadata?.displayReturnsType ?? tool.metadata?.returnsType, | |
| 579 | − strictArgsType: tool.metadata?.argsType, | |
| 580 | − strictReturnsType: tool.metadata?.returnsType, | |
| 581 | − argPreviewKeys: tool.metadata?.argPreviewKeys, | |
| 582 | − operationId: tool.metadata?.operationId, | |
| 583 | − // Note: sourceDts is NOT included — it's too large to send over the wire. | |
| 584 | − // Monaco fetches .d.ts blobs separately via Convex storage URLs. | |
| 585 | − }; | |
| 586 | −} | |
| 587 | − | |
| 588 | −function computeOpenApiSourceQuality( | |
| 589 | − workspaceTools: Map<string, ToolDefinition>, | |
| 590 | −): Record<string, OpenApiSourceQuality> { | |
| 591 | − const grouped = new Map<string, ToolDefinition[]>(); | |
| 592 | − | |
| 593 | − for (const tool of workspaceTools.values()) { | |
| 594 | − const sourceKey = tool.source; | |
| 595 | − if (!sourceKey || !sourceKey.startsWith("openapi:")) continue; | |
| 596 | − const list = grouped.get(sourceKey) ?? []; | |
| 597 | − list.push(tool); | |
| 598 | − grouped.set(sourceKey, list); | |
| 599 | − } | |
| 600 | − | |
| 601 | − const qualityBySource: Record<string, OpenApiSourceQuality> = {}; | |
| 602 | − | |
| 603 | − for (const [sourceKey, tools] of grouped.entries()) { | |
| 604 | − const toolCount = tools.length; | |
| 605 | − | |
| 606 | − let unknownArgsCount = 0; | |
| 607 | − let unknownReturnsCount = 0; | |
| 608 | − let partialUnknownArgsCount = 0; | |
| 609 | − let partialUnknownReturnsCount = 0; | |
| 610 | − | |
| 611 | − for (const tool of tools) { | |
| 612 | − const argsType = tool.metadata?.argsType?.trim() ?? ""; | |
| 613 | − const returnsType = tool.metadata?.returnsType?.trim() ?? ""; | |
| 614 | − | |
| 615 | − if (!argsType || argsType === "Record<string, unknown>") { | |
| 616 | − unknownArgsCount += 1; | |
| 617 | − } | |
| 618 | − if (!returnsType || returnsType === "unknown") { | |
| 619 | − unknownReturnsCount += 1; | |
| 620 | − } | |
| 621 | − if (argsType.includes("unknown")) { | |
| 622 | − partialUnknownArgsCount += 1; | |
| 623 | − } | |
| 624 | − if (returnsType.includes("unknown")) { | |
| 625 | − partialUnknownReturnsCount += 1; | |
| 626 | − } | |
| 627 | − } | |
| 628 | − | |
| 629 | − const argsQuality = toolCount > 0 ? (toolCount - unknownArgsCount) / toolCount : 1; | |
| 630 | − const returnsQuality = toolCount > 0 ? (toolCount - unknownReturnsCount) / toolCount : 1; | |
| 631 | − const overallQuality = (argsQuality + returnsQuality) / 2; | |
| 632 | − | |
| 633 | − qualityBySource[sourceKey] = { | |
| 634 | − sourceKey, | |
| 635 | − toolCount, | |
| 636 | − unknownArgsCount, | |
| 637 | − unknownReturnsCount, | |
| 638 | − partialUnknownArgsCount, | |
| 639 | − partialUnknownReturnsCount, | |
| 640 | − argsQuality, | |
| 641 | − returnsQuality, | |
| 642 | − overallQuality, | |
| 643 | − }; | |
| 644 | − } | |
| 645 | − | |
| 646 | − return qualityBySource; | |
| 647 | −} | |
| 648 | − | |
| 649 | −function listVisibleToolDescriptors( | |
| 650 | − workspaceTools: Map<string, ToolDefinition>, | |
| 651 | − context: { workspaceId: string; actorId?: string; clientId?: string }, | |
| 652 | − policies: AccessPolicyRecord[], | |
| 653 | −): ToolDescriptor[] { | |
| 654 | − const all = [...workspaceTools.values()]; | |
| 655 | − | |
| 656 | − return all | |
| 657 | − .filter((tool) => { | |
| 658 | − const decision = getDecisionForContext(tool, context, policies); | |
| 659 | − return decision !== "deny"; | |
| 660 | − }) | |
| 661 | − .map((tool) => { | |
| 662 | − const decision = getDecisionForContext(tool, context, policies); | |
| 663 | − return toToolDescriptor(tool, decision === "require_approval" ? "required" : "auto"); | |
| 664 | − }); | |
| 665 | −} | |
| 666 | − | |
| 667 | −async function listToolsForContext( | |
| 668 | − ctx: ActionCtx, | |
| 669 | − context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }, | |
| 670 | −): Promise<ToolDescriptor[]> { | |
| 671 | − const [result, policies] = await Promise.all([ | |
| 672 | − getWorkspaceTools(ctx, context.workspaceId), | |
| 673 | − ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }), | |
| 674 | − ]); | |
| 675 | − const typedPolicies = policies as AccessPolicyRecord[]; | |
| 676 | − | |
| 677 | − return listVisibleToolDescriptors(result.tools, context, typedPolicies); | |
| 678 | −} | |
| 679 | − | |
| 680 | −async function listToolsWithWarningsForContext( | |
| 681 | − ctx: ActionCtx, | |
| 682 | − context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }, | |
| 683 | −): Promise<{ | |
| 684 | − tools: ToolDescriptor[]; | |
| 685 | − warnings: string[]; | |
| 686 | − dtsUrls: Record<string, string>; | |
| 687 | − sourceQuality: Record<string, OpenApiSourceQuality>; | |
| 688 | −}> { | |
| 689 | − const [result, policies] = await Promise.all([ | |
| 690 | − getWorkspaceTools(ctx, context.workspaceId), | |
| 691 | − ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }), | |
| 692 | − ]); | |
| 693 | − const typedPolicies = policies as AccessPolicyRecord[]; | |
| 694 | − const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies); | |
| 695 | − const sourceQuality = computeOpenApiSourceQuality(result.tools); | |
| 696 | − | |
| 697 | − // Generate download URLs for .d.ts blobs | |
| 698 | − const dtsUrls: Record<string, string> = {}; | |
| 699 | − for (const entry of result.dtsStorageIds) { | |
| 700 | − try { | |
| 701 | − const url = await ctx.storage.getUrl(entry.storageId); | |
| 702 | − if (url) dtsUrls[entry.sourceKey] = url; | |
| 703 | − } catch { | |
| 704 | − // Storage ID may be stale — skip | |
| 705 | − } | |
| 706 | − } | |
| 707 | − | |
| 708 | − return { | |
| 709 | − tools, | |
| 710 | − warnings: result.warnings, | |
| 711 | − dtsUrls, | |
| 712 | − sourceQuality, | |
| 713 | − }; | |
| 714 | −} | |
| 715 | − | |
| 716 | −function isToolAllowedForTask( | |
| 717 | − task: TaskRecord, | |
| 718 | − toolPath: string, | |
| 719 | − workspaceTools: Map<string, ToolDefinition>, | |
| 720 | − policies: AccessPolicyRecord[], | |
| 721 | −): boolean { | |
| 722 | − const tool = workspaceTools.get(toolPath); | |
| 723 | − if (!tool) return false; | |
| 724 | − return getToolDecision(task, tool, policies) !== "deny"; | |
| 725 | −} | |
| 726 | − | |
| 727 | −async function resolveCredentialHeaders( | |
| 728 | − ctx: ActionCtx, | |
| 729 | − spec: ToolCredentialSpec, | |
| 730 | − task: TaskRecord, | |
| 731 | −): Promise<ResolvedToolCredential | null> { | |
| 732 | − const record = await ctx.runQuery(internal.database.resolveCredential, { | |
| 733 | − workspaceId: task.workspaceId, | |
| 734 | − sourceKey: spec.sourceKey, | |
| 735 | − scope: spec.mode as CredentialScope, | |
| 736 | − actorId: task.actorId, | |
| 737 | − }); | |
| 738 | − | |
| 739 | − const source = record | |
| 740 | − ? await resolveCredentialPayload(record) | |
| 741 | − : spec.staticSecretJson ?? null; | |
| 742 | − if (!source) { | |
| 743 | − return null; | |
| 744 | − } | |
| 745 | − | |
| 746 | − const headers: Record<string, string> = {}; | |
| 747 | − if (spec.authType === "bearer") { | |
| 748 | − const token = String((source as Record<string, unknown>).token ?? "").trim(); | |
| 749 | − if (token) headers.authorization = `Bearer ${token}`; | |
| 750 | − } else if (spec.authType === "apiKey") { | |
| 751 | − const headerName = spec.headerName ?? String((source as Record<string, unknown>).headerName ?? "x-api-key"); | |
| 752 | − const value = String((source as Record<string, unknown>).value ?? (source as Record<string, unknown>).token ?? "").trim(); | |
| 753 | − if (value) headers[headerName] = value; | |
| 754 | − } else if (spec.authType === "basic") { | |
| 755 | − const username = String((source as Record<string, unknown>).username ?? ""); | |
| 756 | − const password = String((source as Record<string, unknown>).password ?? ""); | |
| 757 | − if (username || password) { | |
| 758 | − const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); | |
| 759 | − headers.authorization = `Basic ${encoded}`; | |
| 760 | − } | |
| 761 | − } | |
| 762 | − | |
| 763 | − if (Object.keys(headers).length === 0) { | |
| 764 | − return null; | |
| 765 | − } | |
| 766 | − | |
| 767 | − return { | |
| 768 | − sourceKey: spec.sourceKey, | |
| 769 | − mode: spec.mode, | |
| 770 | − headers, | |
| 771 | − }; | |
| 772 | −} | |
| 773 | − | |
| 774 | −function getGraphqlDecision( | |
| 775 | − task: TaskRecord, | |
| 776 | − tool: ToolDefinition, | |
| 777 | − input: unknown, | |
| 778 | − workspaceTools: Map<string, ToolDefinition>, | |
| 779 | − policies: AccessPolicyRecord[], | |
| 780 | −): { decision: PolicyDecision; effectivePaths: string[] } { | |
| 781 | − const sourceName = tool._graphqlSource!; | |
| 782 | − const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {}; | |
| 783 | − const queryString = typeof payload.query === "string" ? payload.query : ""; | |
| 784 | − | |
| 785 | − if (!queryString.trim()) { | |
| 786 | − return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; | |
| 787 | − } | |
| 788 | − | |
| 789 | − const { fieldPaths } = parseGraphqlOperationPaths(sourceName, queryString); | |
| 790 | − if (fieldPaths.length === 0) { | |
| 791 | − return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; | |
| 792 | − } | |
| 793 | − | |
| 794 | − let worstDecision: PolicyDecision = "allow"; | |
| 795 | − | |
| 796 | − for (const fieldPath of fieldPaths) { | |
| 797 | − const pseudoTool = workspaceTools.get(fieldPath); | |
| 798 | − const fieldDecision = pseudoTool | |
| 799 | − ? getDecisionForContext( | |
| 800 | − pseudoTool, | |
| 801 | − { | |
| 802 | − workspaceId: task.workspaceId, | |
| 803 | − actorId: task.actorId, | |
| 804 | − clientId: task.clientId, | |
| 805 | − }, | |
| 806 | − policies, | |
| 807 | − ) | |
| 808 | − : getDecisionForContext( | |
| 809 | − { ...tool, path: fieldPath, approval: fieldPath.includes(".mutation.") ? "required" : "auto" }, | |
| 810 | − { | |
| 811 | − workspaceId: task.workspaceId, | |
| 812 | − actorId: task.actorId, | |
| 813 | − clientId: task.clientId, | |
| 814 | − }, | |
| 815 | − policies, | |
| 816 | − ); | |
| 817 | − | |
| 818 | − if (fieldDecision === "deny") { | |
| 819 | − worstDecision = "deny"; | |
| 820 | − break; | |
| 821 | − } | |
| 822 | − if (fieldDecision === "require_approval") { | |
| 823 | − worstDecision = "require_approval"; | |
| 824 | − } | |
| 825 | − } | |
| 826 | − | |
| 827 | − return { decision: worstDecision, effectivePaths: fieldPaths }; | |
| 828 | −} | |
| 829 | − | |
| 830 | −async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> { | |
| 831 | − const { toolPath, input, callId } = call; | |
| 832 | − const persistedCall = (await ctx.runMutation(internal.database.upsertToolCallRequested, { | |
| 833 | − taskId: task.id, | |
| 834 | − callId, | |
| 835 | − workspaceId: task.workspaceId, | |
| 836 | − toolPath, | |
| 837 | − input, | |
| 838 | − })) as ToolCallRecord; | |
| 839 | − | |
| 840 | − if (persistedCall.status === "completed") { | |
| 841 | − return persistedCall.output; | |
| 842 | − } | |
| 843 | − | |
| 844 | − if (persistedCall.status === "failed") { | |
| 845 | − throw new Error(persistedCall.error ?? `Tool call failed: ${callId}`); | |
| 846 | − } | |
| 847 | − | |
| 848 | − if (persistedCall.status === "denied") { | |
| 849 | − throw new Error(`${APPROVAL_DENIED_PREFIX}${persistedCall.error ?? persistedCall.toolPath}`); | |
| 850 | − } | |
| 851 | − | |
| 852 | − const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId }); | |
| 853 | − const typedPolicies = policies as AccessPolicyRecord[]; | |
| 854 | − | |
| 855 | − let workspaceTools: Map<string, ToolDefinition> | undefined; | |
| 856 | − let resolvedToolPath = toolPath; | |
| 857 | − let tool = baseTools.get(toolPath); | |
| 858 | − if (!tool) { | |
| 859 | − const result = await getWorkspaceTools(ctx, task.workspaceId); | |
| 860 | − workspaceTools = result.tools; | |
| 861 | − tool = workspaceTools.get(toolPath); | |
| 862 | − | |
| 863 | − if (!tool) { | |
| 864 | − const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools); | |
| 865 | − if (aliasedPath) { | |
| 866 | − resolvedToolPath = aliasedPath; | |
| 867 | − tool = workspaceTools.get(aliasedPath); | |
| 868 | − } | |
| 869 | − } | |
| 870 | − } | |
| 871 | − | |
| 872 | − if (!tool) { | |
| 873 | − const availableTools = workspaceTools ?? baseTools; | |
| 874 | − const suggestions = suggestToolPaths(toolPath, availableTools); | |
| 875 | − const suggestionText = suggestions.length > 0 | |
| 876 | − ? `\nDid you mean: ${suggestions.map((path) => `tools.${path}`).join(", ")}` | |
| 877 | − : ""; | |
| 878 | − throw new Error(`Unknown tool: ${toolPath}${suggestionText}`); | |
| 879 | − } | |
| 880 | − | |
| 881 | − let decision: PolicyDecision; | |
| 882 | − let effectiveToolPath = resolvedToolPath; | |
| 883 | − if (tool._graphqlSource) { | |
| 884 | − if (!workspaceTools) { | |
| 885 | − const result = await getWorkspaceTools(ctx, task.workspaceId); | |
| 886 | − workspaceTools = result.tools; | |
| 887 | − } | |
| 888 | − const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies); | |
| 889 | − decision = result.decision; | |
| 890 | − if (result.effectivePaths.length > 0) { | |
| 891 | − effectiveToolPath = result.effectivePaths.join(", "); | |
| 892 | − } | |
| 893 | − } else { | |
| 894 | − decision = getToolDecision(task, tool, typedPolicies); | |
| 895 | − } | |
| 896 | − | |
| 897 | − const publishToolStarted = persistedCall.status === "requested"; | |
| 898 | − | |
| 899 | − if (decision === "deny") { | |
| 900 | − const deniedMessage = `${effectiveToolPath} (policy denied)`; | |
| 901 | − await ctx.runMutation(internal.database.finishToolCall, { | |
| 902 | − taskId: task.id, | |
| 903 | − callId, | |
| 904 | − status: "denied", | |
| 905 | − error: deniedMessage, | |
| 906 | − }); | |
| 907 | − await publish(ctx, task.id, "task", "tool.call.denied", { | |
| 908 | − taskId: task.id, | |
| 909 | − callId, | |
| 910 | − toolPath: effectiveToolPath, | |
| 911 | − reason: "policy_deny", | |
| 912 | − }); | |
| 913 | − throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); | |
| 914 | − } | |
| 915 | − | |
| 916 | − let credential: ResolvedToolCredential | undefined; | |
| 917 | − if (tool.credential) { | |
| 918 | − const resolved = await resolveCredentialHeaders(ctx, tool.credential, task); | |
| 919 | − if (!resolved) { | |
| 920 | − throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`); | |
| 921 | − } | |
| 922 | − credential = resolved; | |
| 923 | − } | |
| 924 | − | |
| 925 | − if (publishToolStarted) { | |
| 926 | − await publish(ctx, task.id, "task", "tool.call.started", { | |
| 927 | − taskId: task.id, | |
| 928 | − callId, | |
| 929 | − toolPath: effectiveToolPath, | |
| 930 | − approval: decision === "require_approval" ? "required" : "auto", | |
| 931 | − input: asPayload(input), | |
| 932 | − }); | |
| 933 | − } | |
| 934 | − | |
| 935 | − let approvalSatisfied = false; | |
| 936 | − if (persistedCall.approvalId) { | |
| 937 | − const existingApproval = await ctx.runQuery(internal.database.getApproval, { | |
| 938 | − approvalId: persistedCall.approvalId, | |
| 939 | − }); | |
| 940 | − if (!existingApproval) { | |
| 941 | − throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`); | |
| 942 | − } | |
| 943 | − | |
| 944 | − if (existingApproval.status === "pending") { | |
| 945 | − throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`); | |
| 946 | − } | |
| 947 | − | |
| 948 | − if (existingApproval.status === "denied") { | |
| 949 | − const deniedMessage = `${effectiveToolPath} (${existingApproval.id})`; | |
| 950 | − await ctx.runMutation(internal.database.finishToolCall, { | |
| 951 | − taskId: task.id, | |
| 952 | − callId, | |
| 953 | − status: "denied", | |
| 954 | − error: deniedMessage, | |
| 955 | − }); | |
| 956 | − await publish(ctx, task.id, "task", "tool.call.denied", { | |
| 957 | − taskId: task.id, | |
| 958 | − callId, | |
| 959 | − toolPath: effectiveToolPath, | |
| 960 | − approvalId: existingApproval.id, | |
| 961 | − }); | |
| 962 | − throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); | |
| 963 | − } | |
| 964 | − | |
| 965 | − approvalSatisfied = existingApproval.status === "approved"; | |
| 966 | − } | |
| 967 | − | |
| 968 | − if (decision === "require_approval" && !approvalSatisfied) { | |
| 969 | − const approvalId = persistedCall.approvalId ?? createApprovalId(); | |
| 970 | − let approval = await ctx.runQuery(internal.database.getApproval, { | |
| 971 | − approvalId, | |
| 972 | − }); | |
| 973 | − | |
| 974 | − if (!approval) { | |
| 975 | − approval = await ctx.runMutation(internal.database.createApproval, { | |
| 976 | − id: approvalId, | |
| 977 | − taskId: task.id, | |
| 978 | − toolPath: effectiveToolPath, | |
| 979 | − input, | |
| 980 | − }); | |
| 981 | − | |
| 982 | − await publish(ctx, task.id, "approval", "approval.requested", { | |
| 983 | − approvalId: approval.id, | |
| 984 | − taskId: task.id, | |
| 985 | − callId, | |
| 986 | − toolPath: approval.toolPath, | |
| 987 | − input: asPayload(approval.input), | |
| 988 | − createdAt: approval.createdAt, | |
| 989 | − }); | |
| 990 | − } | |
| 991 | − | |
| 992 | − await ctx.runMutation(internal.database.setToolCallPendingApproval, { | |
| 993 | − taskId: task.id, | |
| 994 | − callId, | |
| 995 | − approvalId: approval.id, | |
| 996 | − }); | |
| 997 | − | |
| 998 | − if (approval.status === "pending") { | |
| 999 | − throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`); | |
| 1000 | − } | |
| 1001 | − | |
| 1002 | − if (approval.status === "denied") { | |
| 1003 | − const deniedMessage = `${effectiveToolPath} (${approval.id})`; | |
| 1004 | − await ctx.runMutation(internal.database.finishToolCall, { | |
| 1005 | − taskId: task.id, | |
| 1006 | − callId, | |
| 1007 | − status: "denied", | |
| 1008 | − error: deniedMessage, | |
| 1009 | − }); | |
| 1010 | − await publish(ctx, task.id, "task", "tool.call.denied", { | |
| 1011 | − taskId: task.id, | |
| 1012 | − callId, | |
| 1013 | − toolPath: effectiveToolPath, | |
| 1014 | − approvalId: approval.id, | |
| 1015 | − }); | |
| 1016 | − throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); | |
| 1017 | − } | |
| 1018 | − } | |
| 1019 | − | |
| 1020 | − try { | |
| 1021 | − const context: ToolRunContext = { | |
| 1022 | − taskId: task.id, | |
| 1023 | − workspaceId: task.workspaceId, | |
| 1024 | − actorId: task.actorId, | |
| 1025 | − clientId: task.clientId, | |
| 1026 | − credential, | |
| 1027 | − isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies), | |
| 1028 | − }; | |
| 1029 | − const value = await tool.run(input, context); | |
| 1030 | − await ctx.runMutation(internal.database.finishToolCall, { | |
| 1031 | − taskId: task.id, | |
| 1032 | − callId, | |
| 1033 | − status: "completed", | |
| 1034 | − output: value, | |
| 1035 | − }); | |
| 1036 | − await publish(ctx, task.id, "task", "tool.call.completed", { | |
| 1037 | − taskId: task.id, | |
| 1038 | − callId, | |
| 1039 | − toolPath: effectiveToolPath, | |
| 1040 | − output: asPayload(value), | |
| 1041 | − }); | |
| 1042 | − return value; | |
| 1043 | − } catch (error) { | |
| 1044 | − const message = describeError(error); | |
| 1045 | − await ctx.runMutation(internal.database.finishToolCall, { | |
| 1046 | − taskId: task.id, | |
| 1047 | − callId, | |
| 1048 | − status: "failed", | |
| 1049 | − error: message, | |
| 1050 | − }); | |
| 1051 | − await publish(ctx, task.id, "task", "tool.call.failed", { | |
| 1052 | − taskId: task.id, | |
| 1053 | − callId, | |
| 1054 | − toolPath: effectiveToolPath, | |
| 1055 | − error: message, | |
| 1056 | − }); | |
| 1057 | − throw error; | |
| 1058 | − } | |
| 1059 | −} | |
| 11 | +import { requireCanonicalActor } from "./executor_node/actor_auth"; | |
| 12 | +import { | |
| 13 | + listToolsForContext, | |
| 14 | + listToolsWithWarningsForContext, | |
| 15 | + loadDtsUrls, | |
| 16 | + loadWorkspaceDtsStorageIds, | |
| 17 | +} from "./executor_node/workspace_tools"; | |
| 18 | +import { runQueuedTask } from "./executor_node/task_runner"; | |
| 19 | +import { handleExternalToolCallRequest } from "./executor_node/external_tool_call"; | |
| 1060 | 20 | |
| 1061 | 21 | export const listToolsWithWarnings = action({ |
| 1062 | 22 | args: { |
| @@ -1073,19 +33,13 @@ export const listToolsWithWarnings = action({ · 10 unmodified lines | ||
| 1073 | 33 | warnings: string[]; |
| 1074 | 34 | dtsUrls: Record<string, string>; |
| 1075 | 35 | sourceQuality: Record<string, OpenApiSourceQuality>; |
| 36 | + sourceAuthProfiles: Record<string, SourceAuthProfile>; | |
| 1076 | 37 | }> => { |
| 1077 | − const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, { | |
| 38 | + const canonicalActorId = await requireCanonicalActor(ctx, { | |
| 1078 | 39 | workspaceId: args.workspaceId, |
| 1079 | 40 | sessionId: args.sessionId, |
| 41 | + actorId: args.actorId, | |
| 1080 | 42 | }); |
| 1081 | − const canonicalActorId = actorIdForAccount({ | |
| 1082 | − _id: access.accountId, | |
| 1083 | − provider: access.provider, | |
| 1084 | − providerAccountId: access.providerAccountId, | |
| 1085 | − }); | |
| 1086 | − if (args.actorId && args.actorId !== canonicalActorId) { | |
| 1087 | − throw new Error("actorId must match the authenticated workspace actor"); | |
| 1088 | − } | |
| 1089 | 43 | |
| 1090 | 44 | return await listToolsWithWarningsForContext(ctx, { |
| 1091 | 45 | workspaceId: args.workspaceId, |
| @@ -1095,6 +49,29 @@ export const listToolsWithWarnings = action({ · 3 unmodified lines | ||
| 1095 | 49 | }, |
| 1096 | 50 | }); |
| 1097 | 51 | |
| 52 | +export const listToolDtsUrls = action({ | |
| 53 | + args: { | |
| 54 | + workspaceId: v.id("workspaces"), | |
| 55 | + actorId: v.optional(v.string()), | |
| 56 | + sessionId: v.optional(v.string()), | |
| 57 | + }, | |
| 58 | + handler: async ( | |
| 59 | + ctx, | |
| 60 | + args, | |
| 61 | + ): Promise<{ | |
| 62 | + dtsUrls: Record<string, string>; | |
| 63 | + }> => { | |
| 64 | + await requireCanonicalActor(ctx, { | |
| 65 | + workspaceId: args.workspaceId, | |
| 66 | + sessionId: args.sessionId, | |
| 67 | + actorId: args.actorId, | |
| 68 | + }); | |
| 69 | + const dtsStorageIds = await loadWorkspaceDtsStorageIds(ctx, args.workspaceId); | |
| 70 | + const dtsUrls = await loadDtsUrls(ctx, dtsStorageIds); | |
| 71 | + return { dtsUrls }; | |
| 72 | + }, | |
| 73 | +}); | |
| 74 | + | |
| 1098 | 75 | export const listToolsInternal = internalAction({ |
| 1099 | 76 | args: { |
| 1100 | 77 | workspaceId: v.id("workspaces"), |
| @@ -1120,6 +97,7 @@ export const listToolsWithWarningsInternal = internalAction({ · 19 unmodified lines | ||
| 1120 | 97 | warnings: string[]; |
| 1121 | 98 | dtsUrls: Record<string, string>; |
| 1122 | 99 | sourceQuality: Record<string, OpenApiSourceQuality>; |
| 100 | + sourceAuthProfiles: Record<string, SourceAuthProfile>; | |
| 1123 | 101 | }> => { |
| 1124 | 102 | return await listToolsWithWarningsForContext(ctx, args); |
| 1125 | 103 | }, |
| @@ -1132,226 +110,10 @@ export const handleExternalToolCall = internalAction({ · 6 unmodified lines | ||
| 1132 | 110 | toolPath: v.string(), |
| 1133 | 111 | input: v.optional(v.any()), |
| 1134 | 112 | }, |
| 1135 | − handler: async (ctx, args): Promise<ToolCallResult> => { | |
| 1136 | − const task = (await ctx.runQuery(internal.database.getTask, { | |
| 1137 | − taskId: args.runId, | |
| 1138 | − })) as TaskRecord | null; | |
| 1139 | − if (!task) { | |
| 1140 | − return { | |
| 1141 | − ok: false, | |
| 1142 | − kind: "failed", | |
| 1143 | − error: `Run not found: ${args.runId}`, | |
| 1144 | − }; | |
| 1145 | − } | |
| 1146 | − | |
| 1147 | − try { | |
| 1148 | − const value = await invokeTool(ctx, task, { | |
| 1149 | − runId: args.runId, | |
| 1150 | − callId: args.callId, | |
| 1151 | − toolPath: args.toolPath, | |
| 1152 | − input: args.input ?? {}, | |
| 1153 | − }); | |
| 1154 | − return { ok: true, value }; | |
| 1155 | − } catch (error) { | |
| 1156 | − const message = describeError(error); | |
| 1157 | − if (message.startsWith(APPROVAL_PENDING_PREFIX)) { | |
| 1158 | − const approvalId = message.replace(APPROVAL_PENDING_PREFIX, "").trim(); | |
| 1159 | − return { | |
| 1160 | − ok: false, | |
| 1161 | − kind: "pending", | |
| 1162 | − approvalId, | |
| 1163 | − retryAfterMs: 500, | |
| 1164 | − error: "Approval pending", | |
| 1165 | − }; | |
| 1166 | − } | |
| 1167 | − if (message.startsWith(APPROVAL_DENIED_PREFIX)) { | |
| 1168 | − return { | |
| 1169 | − ok: false, | |
| 1170 | − kind: "denied", | |
| 1171 | − error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(), | |
| 1172 | − }; | |
| 1173 | − } | |
| 1174 | − | |
| 1175 | − return { | |
| 1176 | − ok: false, | |
| 1177 | − kind: "failed", | |
| 1178 | − error: message, | |
| 1179 | − }; | |
| 1180 | − } | |
| 1181 | − }, | |
| 113 | + handler: async (ctx, args): Promise<ToolCallResult> => await handleExternalToolCallRequest(ctx, args), | |
| 1182 | 114 | }); |
| 1183 | 115 | |
| 1184 | 116 | export const runTask = internalAction({ |
| 1185 | 117 | args: { taskId: v.string() }, |
| 1186 | − handler: async (ctx, args) => { | |
| 1187 | − const task = (await ctx.runQuery(internal.database.getTask, { taskId: args.taskId })) as TaskRecord | null; | |
| 1188 | − if (!task || task.status !== "queued") { | |
| 1189 | − return null; | |
| 1190 | − } | |
| 1191 | − | |
| 1192 | − if (!isKnownRuntimeId(task.runtimeId)) { | |
| 1193 | − const failed = await ctx.runMutation(internal.database.markTaskFinished, { | |
| 1194 | − taskId: args.taskId, | |
| 1195 | − status: "failed", | |
| 1196 | − stdout: "", | |
| 1197 | − stderr: "", | |
| 1198 | − error: `Runtime not found: ${task.runtimeId}`, | |
| 1199 | − }); | |
| 1200 | − | |
| 1201 | − if (failed) { | |
| 1202 | − await publish(ctx, args.taskId, "task", "task.failed", { | |
| 1203 | − taskId: args.taskId, | |
| 1204 | − status: failed.status, | |
| 1205 | − error: failed.error, | |
| 1206 | − }); | |
| 1207 | − } | |
| 1208 | − return null; | |
| 1209 | − } | |
| 1210 | − | |
| 1211 | − if (task.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID && !isCloudflareWorkerLoaderConfigured()) { | |
| 1212 | − const failed = await ctx.runMutation(internal.database.markTaskFinished, { | |
| 1213 | − taskId: args.taskId, | |
| 1214 | − status: "failed", | |
| 1215 | − stdout: "", | |
| 1216 | − stderr: "", | |
| 1217 | − error: `Runtime is not configured: ${task.runtimeId}`, | |
| 1218 | − }); | |
| 1219 | − | |
| 1220 | − if (failed) { | |
| 1221 | − await publish(ctx, args.taskId, "task", "task.failed", { | |
| 1222 | − taskId: args.taskId, | |
| 1223 | − status: failed.status, | |
| 1224 | − error: failed.error, | |
| 1225 | − }); | |
| 1226 | − } | |
| 1227 | − return null; | |
| 1228 | − } | |
| 1229 | − | |
| 1230 | − try { | |
| 1231 | − const running = (await ctx.runMutation(internal.database.markTaskRunning, { | |
| 1232 | − taskId: args.taskId, | |
| 1233 | − })) as TaskRecord | null; | |
| 1234 | − if (!running) { | |
| 1235 | − return null; | |
| 1236 | − } | |
| 1237 | − | |
| 1238 | − await publish(ctx, args.taskId, "task", "task.running", { | |
| 1239 | − taskId: args.taskId, | |
| 1240 | − status: running.status, | |
| 1241 | − startedAt: running.startedAt, | |
| 1242 | − }); | |
| 1243 | − | |
| 1244 | − if (running.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID) { | |
| 1245 | − const dispatchResult = await dispatchCodeWithCloudflareWorkerLoader({ | |
| 1246 | − taskId: args.taskId, | |
| 1247 | − code: running.code, | |
| 1248 | − timeoutMs: running.timeoutMs, | |
| 1249 | − }); | |
| 1250 | − | |
| 1251 | − if (!dispatchResult.ok) { | |
| 1252 | − const failed = await ctx.runMutation(internal.database.markTaskFinished, { | |
| 1253 | − taskId: args.taskId, | |
| 1254 | − status: "failed", | |
| 1255 | − stdout: "", | |
| 1256 | − stderr: "", | |
| 1257 | − error: dispatchResult.error, | |
| 1258 | − }); | |
| 1259 | − | |
| 1260 | − if (failed) { | |
| 1261 | − await publish(ctx, args.taskId, "task", "task.failed", { | |
| 1262 | − taskId: args.taskId, | |
| 1263 | − status: failed.status, | |
| 1264 | − error: failed.error, | |
| 1265 | − completedAt: failed.completedAt, | |
| 1266 | − }); | |
| 1267 | − } | |
| 1268 | − return null; | |
| 1269 | − } | |
| 1270 | − | |
| 1271 | − await publish(ctx, args.taskId, "task", "task.dispatched", { | |
| 1272 | − taskId: args.taskId, | |
| 1273 | − runtimeId: running.runtimeId, | |
| 1274 | − dispatchId: dispatchResult.dispatchId, | |
| 1275 | − durationMs: dispatchResult.durationMs, | |
| 1276 | − }); | |
| 1277 | − return null; | |
| 1278 | − } | |
| 1279 | − | |
| 1280 | − const runtimeResult = await (async () => { | |
| 1281 | − const adapter = new InProcessExecutionAdapter({ | |
| 1282 | − runId: args.taskId, | |
| 1283 | − invokeTool: async (call) => await invokeTool(ctx, running, call), | |
| 1284 | − emitOutput: async (event) => { | |
| 1285 | − await ctx.runMutation(internal.executor.appendRuntimeOutput, { | |
| 1286 | − runId: event.runId, | |
| 1287 | − stream: event.stream, | |
| 1288 | − line: event.line, | |
| 1289 | − timestamp: event.timestamp, | |
| 1290 | − }); | |
| 1291 | − }, | |
| 1292 | − }); | |
| 1293 | − | |
| 1294 | − return await runCodeWithAdapter( | |
| 1295 | − { | |
| 1296 | − taskId: args.taskId, | |
| 1297 | − code: running.code, | |
| 1298 | − timeoutMs: running.timeoutMs, | |
| 1299 | − }, | |
| 1300 | − adapter, | |
| 1301 | − ); | |
| 1302 | − })(); | |
| 1303 | − | |
| 1304 | − const finished = await ctx.runMutation(internal.database.markTaskFinished, { | |
| 1305 | − taskId: args.taskId, | |
| 1306 | − status: runtimeResult.status, | |
| 1307 | − stdout: runtimeResult.stdout, | |
| 1308 | − stderr: runtimeResult.stderr, | |
| 1309 | − exitCode: runtimeResult.exitCode, | |
| 1310 | − error: runtimeResult.error, | |
| 1311 | − }); | |
| 1312 | − | |
| 1313 | − if (!finished) { | |
| 1314 | − return null; | |
| 1315 | − } | |
| 1316 | − | |
| 1317 | − const terminalEvent = | |
| 1318 | − runtimeResult.status === "completed" | |
| 1319 | − ? "task.completed" | |
| 1320 | − : runtimeResult.status === "timed_out" | |
| 1321 | − ? "task.timed_out" | |
| 1322 | − : runtimeResult.status === "denied" | |
| 1323 | − ? "task.denied" | |
| 1324 | − : "task.failed"; | |
| 1325 | − | |
| 1326 | − await publish(ctx, args.taskId, "task", terminalEvent, { | |
| 1327 | − taskId: args.taskId, | |
| 1328 | − status: finished.status, | |
| 1329 | − exitCode: finished.exitCode, | |
| 1330 | − durationMs: runtimeResult.durationMs, | |
| 1331 | − error: finished.error, | |
| 1332 | − completedAt: finished.completedAt, | |
| 1333 | − }); | |
| 1334 | − } catch (error) { | |
| 1335 | − const message = describeError(error); | |
| 1336 | − const denied = message.startsWith(APPROVAL_DENIED_PREFIX); | |
| 1337 | − const finished = await ctx.runMutation(internal.database.markTaskFinished, { | |
| 1338 | − taskId: args.taskId, | |
| 1339 | − status: denied ? "denied" : "failed", | |
| 1340 | − stdout: "", | |
| 1341 | − stderr: "", | |
| 1342 | − error: denied ? message.replace(APPROVAL_DENIED_PREFIX, "") : message, | |
| 1343 | − }); | |
| 1344 | − | |
| 1345 | − if (finished) { | |
| 1346 | − await publish(ctx, args.taskId, "task", denied ? "task.denied" : "task.failed", { | |
| 1347 | − taskId: args.taskId, | |
| 1348 | − status: finished.status, | |
| 1349 | − error: finished.error, | |
| 1350 | − completedAt: finished.completedAt, | |
| 1351 | − }); | |
| 1352 | − } | |
| 1353 | − } | |
| 1354 | − | |
| 1355 | − return null; | |
| 1356 | − }, | |
| 118 | + handler: async (ctx, args) => await runQueuedTask(ctx, args), | |
| 1357 | 119 | }); |
| @@ -0,0 +1,29 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import type { ActionCtx } from "../_generated/server"; | |
| 4 | +import type { Id } from "../_generated/dataModel"; | |
| 5 | +import { internal } from "../_generated/api"; | |
| 6 | +import { actorIdForAccount } from "../../core/src/identity"; | |
| 7 | + | |
| 8 | +export async function requireCanonicalActor( | |
| 9 | + ctx: ActionCtx, | |
| 10 | + args: { | |
| 11 | + workspaceId: Id<"workspaces">; | |
| 12 | + sessionId?: string; | |
| 13 | + actorId?: string; | |
| 14 | + }, | |
| 15 | +): Promise<string> { | |
| 16 | + const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, { | |
| 17 | + workspaceId: args.workspaceId, | |
| 18 | + sessionId: args.sessionId, | |
| 19 | + }); | |
| 20 | + const canonicalActorId = actorIdForAccount({ | |
| 21 | + _id: access.accountId, | |
| 22 | + provider: access.provider, | |
| 23 | + providerAccountId: access.providerAccountId, | |
| 24 | + }); | |
| 25 | + if (args.actorId && args.actorId !== canonicalActorId) { | |
| 26 | + throw new Error("actorId must match the authenticated workspace actor"); | |
| 27 | + } | |
| 28 | + return canonicalActorId; | |
| 29 | +} | |
| @@ -0,0 +1,19 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import type { ActionCtx } from "../_generated/server"; | |
| 4 | +import { internal } from "../_generated/api"; | |
| 5 | + | |
| 6 | +export async function publishTaskEvent( | |
| 7 | + ctx: ActionCtx, | |
| 8 | + taskId: string, | |
| 9 | + eventName: "task" | "approval", | |
| 10 | + type: string, | |
| 11 | + payload: Record<string, unknown>, | |
| 12 | +): Promise<void> { | |
| 13 | + await ctx.runMutation(internal.database.createTaskEvent, { | |
| 14 | + taskId, | |
| 15 | + eventName, | |
| 16 | + type, | |
| 17 | + payload, | |
| 18 | + }); | |
| 19 | +} | |
| @@ -0,0 +1,64 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import type { ActionCtx } from "../_generated/server"; | |
| 4 | +import { internal } from "../_generated/api"; | |
| 5 | +import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants"; | |
| 6 | +import type { TaskRecord, ToolCallResult } from "../../core/src/types"; | |
| 7 | +import { describeError } from "../../core/src/utils"; | |
| 8 | +import { invokeTool } from "./tool_invocation"; | |
| 9 | + | |
| 10 | +export async function handleExternalToolCallRequest( | |
| 11 | + ctx: ActionCtx, | |
| 12 | + args: { | |
| 13 | + runId: string; | |
| 14 | + callId: string; | |
| 15 | + toolPath: string; | |
| 16 | + input?: unknown; | |
| 17 | + }, | |
| 18 | +): Promise<ToolCallResult> { | |
| 19 | + const task = (await ctx.runQuery(internal.database.getTask, { | |
| 20 | + taskId: args.runId, | |
| 21 | + })) as TaskRecord | null; | |
| 22 | + if (!task) { | |
| 23 | + return { | |
| 24 | + ok: false, | |
| 25 | + kind: "failed", | |
| 26 | + error: `Run not found: ${args.runId}`, | |
| 27 | + }; | |
| 28 | + } | |
| 29 | + | |
| 30 | + try { | |
| 31 | + const value = await invokeTool(ctx, task, { | |
| 32 | + runId: args.runId, | |
| 33 | + callId: args.callId, | |
| 34 | + toolPath: args.toolPath, | |
| 35 | + input: args.input ?? {}, | |
| 36 | + }); | |
| 37 | + return { ok: true, value }; | |
| 38 | + } catch (error) { | |
| 39 | + const message = describeError(error); | |
| 40 | + if (message.startsWith(APPROVAL_PENDING_PREFIX)) { | |
| 41 | + const approvalId = message.replace(APPROVAL_PENDING_PREFIX, "").trim(); | |
| 42 | + return { | |
| 43 | + ok: false, | |
| 44 | + kind: "pending", | |
| 45 | + approvalId, | |
| 46 | + retryAfterMs: 500, | |
| 47 | + error: "Approval pending", | |
| 48 | + }; | |
| 49 | + } | |
| 50 | + if (message.startsWith(APPROVAL_DENIED_PREFIX)) { | |
| 51 | + return { | |
| 52 | + ok: false, | |
| 53 | + kind: "denied", | |
| 54 | + error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(), | |
| 55 | + }; | |
| 56 | + } | |
| 57 | + | |
| 58 | + return { | |
| 59 | + ok: false, | |
| 60 | + kind: "failed", | |
| 61 | + error: message, | |
| 62 | + }; | |
| 63 | + } | |
| 64 | +} | |
| @@ -0,0 +1,75 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import type { | |
| 4 | + AccessPolicyRecord, | |
| 5 | + PolicyDecision, | |
| 6 | + TaskRecord, | |
| 7 | + ToolDefinition, | |
| 8 | +} from "../../core/src/types"; | |
| 9 | + | |
| 10 | +function matchesToolPath(pattern: string, toolPath: string): boolean { | |
| 11 | + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); | |
| 12 | + const regex = new RegExp(`^${escaped}$`); | |
| 13 | + return regex.test(toolPath); | |
| 14 | +} | |
| 15 | + | |
| 16 | +function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientId?: string): number { | |
| 17 | + 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); | |
| 21 | + score += policy.priority; | |
| 22 | + return score; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export function getDecisionForContext( | |
| 26 | + tool: ToolDefinition, | |
| 27 | + context: { workspaceId: string; actorId?: string; clientId?: string }, | |
| 28 | + policies: AccessPolicyRecord[], | |
| 29 | +): PolicyDecision { | |
| 30 | + if (tool.path === "discover") { | |
| 31 | + return "allow"; | |
| 32 | + } | |
| 33 | + | |
| 34 | + const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow"; | |
| 35 | + const candidates = policies | |
| 36 | + .filter((policy) => { | |
| 37 | + if (policy.actorId && policy.actorId !== context.actorId) return false; | |
| 38 | + if (policy.clientId && policy.clientId !== context.clientId) return false; | |
| 39 | + return matchesToolPath(policy.toolPathPattern, tool.path); | |
| 40 | + }) | |
| 41 | + .sort( | |
| 42 | + (a, b) => | |
| 43 | + policySpecificity(b, context.actorId, context.clientId) | |
| 44 | + - policySpecificity(a, context.actorId, context.clientId), | |
| 45 | + ); | |
| 46 | + | |
| 47 | + return candidates[0]?.decision ?? defaultDecision; | |
| 48 | +} | |
| 49 | + | |
| 50 | +export function getToolDecision( | |
| 51 | + task: TaskRecord, | |
| 52 | + tool: ToolDefinition, | |
| 53 | + policies: AccessPolicyRecord[], | |
| 54 | +): PolicyDecision { | |
| 55 | + return getDecisionForContext( | |
| 56 | + tool, | |
| 57 | + { | |
| 58 | + workspaceId: task.workspaceId, | |
| 59 | + actorId: task.actorId, | |
| 60 | + clientId: task.clientId, | |
| 61 | + }, | |
| 62 | + policies, | |
| 63 | + ); | |
| 64 | +} | |
| 65 | + | |
| 66 | +export function isToolAllowedForTask( | |
| 67 | + task: TaskRecord, | |
| 68 | + toolPath: string, | |
| 69 | + workspaceTools: Map<string, ToolDefinition>, | |
| 70 | + policies: AccessPolicyRecord[], | |
| 71 | +): boolean { | |
| 72 | + const tool = workspaceTools.get(toolPath); | |
| 73 | + if (!tool) return false; | |
| 74 | + return getToolDecision(task, tool, policies) !== "deny"; | |
| 75 | +} | |
| @@ -0,0 +1,175 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import type { ActionCtx } from "../_generated/server"; | |
| 4 | +import { internal } from "../_generated/api"; | |
| 5 | +import { InProcessExecutionAdapter } from "../../core/src/adapters/in-process-execution-adapter"; | |
| 6 | +import { APPROVAL_DENIED_PREFIX } from "../../core/src/execution-constants"; | |
| 7 | +import { dispatchCodeWithCloudflareWorkerLoader } from "../../core/src/runtimes/cloudflare-worker-loader-runtime"; | |
| 8 | +import { | |
| 9 | + CLOUDFLARE_WORKER_LOADER_RUNTIME_ID, | |
| 10 | + isCloudflareWorkerLoaderConfigured, | |
| 11 | + isKnownRuntimeId, | |
| 12 | +} from "../../core/src/runtimes/runtime-catalog"; | |
| 13 | +import { runCodeWithAdapter } from "../../core/src/runtimes/runtime-core"; | |
| 14 | +import type { TaskRecord } from "../../core/src/types"; | |
| 15 | +import { describeError } from "../../core/src/utils"; | |
| 16 | +import { publishTaskEvent } from "./events"; | |
| 17 | +import { invokeTool } from "./tool_invocation"; | |
| 18 | + | |
| 19 | +export async function runQueuedTask( | |
| 20 | + ctx: ActionCtx, | |
| 21 | + args: { taskId: string }, | |
| 22 | +): Promise<null> { | |
| 23 | + const task = (await ctx.runQuery(internal.database.getTask, { taskId: args.taskId })) as TaskRecord | null; | |
| 24 | + if (!task || task.status !== "queued") { | |
| 25 | + return null; | |
| 26 | + } | |
| 27 | + | |
| 28 | + if (!isKnownRuntimeId(task.runtimeId)) { | |
| 29 | + const failed = await ctx.runMutation(internal.database.markTaskFinished as any, { | |
| 30 | + taskId: args.taskId, | |
| 31 | + status: "failed", | |
| 32 | + error: `Runtime not found: ${task.runtimeId}`, | |
| 33 | + }); | |
| 34 | + | |
| 35 | + if (failed) { | |
| 36 | + await publishTaskEvent(ctx, args.taskId, "task", "task.failed", { | |
| 37 | + taskId: args.taskId, | |
| 38 | + status: failed.status, | |
| 39 | + error: failed.error, | |
| 40 | + }); | |
| 41 | + } | |
| 42 | + return null; | |
| 43 | + } | |
| 44 | + | |
| 45 | + if (task.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID && !isCloudflareWorkerLoaderConfigured()) { | |
| 46 | + const failed = await ctx.runMutation(internal.database.markTaskFinished as any, { | |
| 47 | + taskId: args.taskId, | |
| 48 | + status: "failed", | |
| 49 | + error: `Runtime is not configured: ${task.runtimeId}`, | |
| 50 | + }); | |
| 51 | + | |
| 52 | + if (failed) { | |
| 53 | + await publishTaskEvent(ctx, args.taskId, "task", "task.failed", { | |
| 54 | + taskId: args.taskId, | |
| 55 | + status: failed.status, | |
| 56 | + error: failed.error, | |
| 57 | + }); | |
| 58 | + } | |
| 59 | + return null; | |
| 60 | + } | |
| 61 | + | |
| 62 | + try { | |
| 63 | + const running = (await ctx.runMutation(internal.database.markTaskRunning, { | |
| 64 | + taskId: args.taskId, | |
| 65 | + })) as TaskRecord | null; | |
| 66 | + if (!running) { | |
| 67 | + return null; | |
| 68 | + } | |
| 69 | + | |
| 70 | + await publishTaskEvent(ctx, args.taskId, "task", "task.running", { | |
| 71 | + taskId: args.taskId, | |
| 72 | + status: running.status, | |
| 73 | + startedAt: running.startedAt, | |
| 74 | + }); | |
| 75 | + | |
| 76 | + if (running.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID) { | |
| 77 | + const dispatchResult = await dispatchCodeWithCloudflareWorkerLoader({ | |
| 78 | + taskId: args.taskId, | |
| 79 | + code: running.code, | |
| 80 | + timeoutMs: running.timeoutMs, | |
| 81 | + }); | |
| 82 | + | |
| 83 | + if (!dispatchResult.ok) { | |
| 84 | + const failed = await ctx.runMutation(internal.database.markTaskFinished as any, { | |
| 85 | + taskId: args.taskId, | |
| 86 | + status: "failed", | |
| 87 | + error: dispatchResult.error, | |
| 88 | + }); | |
| 89 | + | |
| 90 | + if (failed) { | |
| 91 | + await publishTaskEvent(ctx, args.taskId, "task", "task.failed", { | |
| 92 | + taskId: args.taskId, | |
| 93 | + status: failed.status, | |
| 94 | + error: failed.error, | |
| 95 | + completedAt: failed.completedAt, | |
| 96 | + }); | |
| 97 | + } | |
| 98 | + return null; | |
| 99 | + } | |
| 100 | + | |
| 101 | + await publishTaskEvent(ctx, args.taskId, "task", "task.dispatched", { | |
| 102 | + taskId: args.taskId, | |
| 103 | + runtimeId: running.runtimeId, | |
| 104 | + dispatchId: dispatchResult.dispatchId, | |
| 105 | + durationMs: dispatchResult.durationMs, | |
| 106 | + }); | |
| 107 | + return null; | |
| 108 | + } | |
| 109 | + | |
| 110 | + const runtimeResult = await (async () => { | |
| 111 | + const adapter = new InProcessExecutionAdapter({ | |
| 112 | + runId: args.taskId, | |
| 113 | + invokeTool: async (call) => await invokeTool(ctx, running, call), | |
| 114 | + }); | |
| 115 | + | |
| 116 | + return await runCodeWithAdapter( | |
| 117 | + { | |
| 118 | + taskId: args.taskId, | |
| 119 | + code: running.code, | |
| 120 | + timeoutMs: running.timeoutMs, | |
| 121 | + }, | |
| 122 | + adapter, | |
| 123 | + ); | |
| 124 | + })(); | |
| 125 | + | |
| 126 | + const finished = await ctx.runMutation(internal.database.markTaskFinished as any, { | |
| 127 | + taskId: args.taskId, | |
| 128 | + status: runtimeResult.status, | |
| 129 | + result: runtimeResult.result, | |
| 130 | + exitCode: runtimeResult.exitCode, | |
| 131 | + error: runtimeResult.error, | |
| 132 | + }); | |
| 133 | + | |
| 134 | + if (!finished) { | |
| 135 | + return null; | |
| 136 | + } | |
| 137 | + | |
| 138 | + const terminalEvent = | |
| 139 | + runtimeResult.status === "completed" | |
| 140 | + ? "task.completed" | |
| 141 | + : runtimeResult.status === "timed_out" | |
| 142 | + ? "task.timed_out" | |
| 143 | + : runtimeResult.status === "denied" | |
| 144 | + ? "task.denied" | |
| 145 | + : "task.failed"; | |
| 146 | + | |
| 147 | + await publishTaskEvent(ctx, args.taskId, "task", terminalEvent, { | |
| 148 | + taskId: args.taskId, | |
| 149 | + status: finished.status, | |
| 150 | + exitCode: finished.exitCode, | |
| 151 | + durationMs: runtimeResult.durationMs, | |
| 152 | + error: finished.error, | |
| 153 | + completedAt: finished.completedAt, | |
| 154 | + }); | |
| 155 | + } catch (error) { | |
| 156 | + const message = describeError(error); | |
| 157 | + const denied = message.startsWith(APPROVAL_DENIED_PREFIX); | |
| 158 | + const finished = await ctx.runMutation(internal.database.markTaskFinished as any, { | |
| 159 | + taskId: args.taskId, | |
| 160 | + status: denied ? "denied" : "failed", | |
| 161 | + error: denied ? message.replace(APPROVAL_DENIED_PREFIX, "") : message, | |
| 162 | + }); | |
| 163 | + | |
| 164 | + if (finished) { | |
| 165 | + await publishTaskEvent(ctx, args.taskId, "task", denied ? "task.denied" : "task.failed", { | |
| 166 | + taskId: args.taskId, | |
| 167 | + status: finished.status, | |
| 168 | + error: finished.error, | |
| 169 | + completedAt: finished.completedAt, | |
| 170 | + }); | |
| 171 | + } | |
| 172 | + } | |
| 173 | + | |
| 174 | + return null; | |
| 175 | +} | |
| @@ -0,0 +1,103 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import type { | |
| 4 | + AccessPolicyRecord, | |
| 5 | + OpenApiSourceQuality, | |
| 6 | + ToolDefinition, | |
| 7 | + ToolDescriptor, | |
| 8 | +} from "../../core/src/types"; | |
| 9 | +import { getDecisionForContext } from "./policy"; | |
| 10 | + | |
| 11 | +function toToolDescriptor(tool: ToolDefinition, approval: "auto" | "required"): ToolDescriptor { | |
| 12 | + return { | |
| 13 | + path: tool.path, | |
| 14 | + description: tool.description, | |
| 15 | + approval, | |
| 16 | + source: tool.source, | |
| 17 | + argsType: tool.metadata?.displayArgsType ?? tool.metadata?.argsType, | |
| 18 | + returnsType: tool.metadata?.displayReturnsType ?? tool.metadata?.returnsType, | |
| 19 | + strictArgsType: tool.metadata?.argsType, | |
| 20 | + strictReturnsType: tool.metadata?.returnsType, | |
| 21 | + argPreviewKeys: tool.metadata?.argPreviewKeys, | |
| 22 | + operationId: tool.metadata?.operationId, | |
| 23 | + }; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function computeOpenApiSourceQuality( | |
| 27 | + workspaceTools: Map<string, ToolDefinition>, | |
| 28 | +): Record<string, OpenApiSourceQuality> { | |
| 29 | + const grouped = new Map<string, ToolDefinition[]>(); | |
| 30 | + | |
| 31 | + for (const tool of workspaceTools.values()) { | |
| 32 | + const sourceKey = tool.source; | |
| 33 | + if (!sourceKey || !sourceKey.startsWith("openapi:")) continue; | |
| 34 | + const list = grouped.get(sourceKey) ?? []; | |
| 35 | + list.push(tool); | |
| 36 | + grouped.set(sourceKey, list); | |
| 37 | + } | |
| 38 | + | |
| 39 | + const qualityBySource: Record<string, OpenApiSourceQuality> = {}; | |
| 40 | + | |
| 41 | + for (const [sourceKey, tools] of grouped.entries()) { | |
| 42 | + const toolCount = tools.length; | |
| 43 | + | |
| 44 | + let unknownArgsCount = 0; | |
| 45 | + let unknownReturnsCount = 0; | |
| 46 | + let partialUnknownArgsCount = 0; | |
| 47 | + let partialUnknownReturnsCount = 0; | |
| 48 | + | |
| 49 | + for (const tool of tools) { | |
| 50 | + const argsType = tool.metadata?.argsType?.trim() ?? ""; | |
| 51 | + const returnsType = tool.metadata?.returnsType?.trim() ?? ""; | |
| 52 | + | |
| 53 | + if (!argsType || argsType === "Record<string, unknown>") { | |
| 54 | + unknownArgsCount += 1; | |
| 55 | + } | |
| 56 | + if (!returnsType || returnsType === "unknown") { | |
| 57 | + unknownReturnsCount += 1; | |
| 58 | + } | |
| 59 | + if (argsType.includes("unknown")) { | |
| 60 | + partialUnknownArgsCount += 1; | |
| 61 | + } | |
| 62 | + if (returnsType.includes("unknown")) { | |
| 63 | + partialUnknownReturnsCount += 1; | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + const argsQuality = toolCount > 0 ? (toolCount - unknownArgsCount) / toolCount : 1; | |
| 68 | + const returnsQuality = toolCount > 0 ? (toolCount - unknownReturnsCount) / toolCount : 1; | |
| 69 | + const overallQuality = (argsQuality + returnsQuality) / 2; | |
| 70 | + | |
| 71 | + qualityBySource[sourceKey] = { | |
| 72 | + sourceKey, | |
| 73 | + toolCount, | |
| 74 | + unknownArgsCount, | |
| 75 | + unknownReturnsCount, | |
| 76 | + partialUnknownArgsCount, | |
| 77 | + partialUnknownReturnsCount, | |
| 78 | + argsQuality, | |
| 79 | + returnsQuality, | |
| 80 | + overallQuality, | |
| 81 | + }; | |
| 82 | + } | |
| 83 | + | |
| 84 | + return qualityBySource; | |
| 85 | +} | |
| 86 | + | |
| 87 | +export function listVisibleToolDescriptors( | |
| 88 | + workspaceTools: Map<string, ToolDefinition>, | |
| 89 | + context: { workspaceId: string; actorId?: string; clientId?: string }, | |
| 90 | + policies: AccessPolicyRecord[], | |
| 91 | +): ToolDescriptor[] { | |
| 92 | + const all = [...workspaceTools.values()]; | |
| 93 | + | |
| 94 | + return all | |
| 95 | + .filter((tool) => { | |
| 96 | + const decision = getDecisionForContext(tool, context, policies); | |
| 97 | + return decision !== "deny"; | |
| 98 | + }) | |
| 99 | + .map((tool) => { | |
| 100 | + const decision = getDecisionForContext(tool, context, policies); | |
| 101 | + return toToolDescriptor(tool, decision === "require_approval" ? "required" : "auto"); | |
| 102 | + }); | |
| 103 | +} | |
| @@ -0,0 +1,374 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import type { ActionCtx } from "../_generated/server"; | |
| 4 | +import { internal } from "../_generated/api"; | |
| 5 | +import { resolveCredentialPayload } from "../../core/src/credential-providers"; | |
| 6 | +import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants"; | |
| 7 | +import { parseGraphqlOperationPaths } from "../../core/src/tool-sources"; | |
| 8 | +import type { | |
| 9 | + AccessPolicyRecord, | |
| 10 | + CredentialScope, | |
| 11 | + PolicyDecision, | |
| 12 | + ResolvedToolCredential, | |
| 13 | + TaskRecord, | |
| 14 | + ToolCallRecord, | |
| 15 | + ToolCallRequest, | |
| 16 | + ToolCredentialSpec, | |
| 17 | + ToolDefinition, | |
| 18 | + ToolRunContext, | |
| 19 | +} from "../../core/src/types"; | |
| 20 | +import { asPayload, describeError } from "../../core/src/utils"; | |
| 21 | +import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy"; | |
| 22 | +import { resolveAliasedToolPath, suggestToolPaths } from "./tool_paths"; | |
| 23 | +import { baseTools, getWorkspaceTools } from "./workspace_tools"; | |
| 24 | +import { publishTaskEvent } from "./events"; | |
| 25 | + | |
| 26 | +function createApprovalId(): string { | |
| 27 | + return `approval_${crypto.randomUUID()}`; | |
| 28 | +} | |
| 29 | + | |
| 30 | +async function resolveCredentialHeaders( | |
| 31 | + ctx: ActionCtx, | |
| 32 | + spec: ToolCredentialSpec, | |
| 33 | + task: TaskRecord, | |
| 34 | +): Promise<ResolvedToolCredential | null> { | |
| 35 | + const record = await ctx.runQuery(internal.database.resolveCredential, { | |
| 36 | + workspaceId: task.workspaceId, | |
| 37 | + sourceKey: spec.sourceKey, | |
| 38 | + scope: spec.mode as CredentialScope, | |
| 39 | + actorId: task.actorId, | |
| 40 | + }); | |
| 41 | + | |
| 42 | + const source = record | |
| 43 | + ? await resolveCredentialPayload(record) | |
| 44 | + : spec.staticSecretJson ?? null; | |
| 45 | + if (!source) { | |
| 46 | + return null; | |
| 47 | + } | |
| 48 | + | |
| 49 | + const headers: Record<string, string> = {}; | |
| 50 | + if (spec.authType === "bearer") { | |
| 51 | + const token = String((source as Record<string, unknown>).token ?? "").trim(); | |
| 52 | + if (token) headers.authorization = `Bearer ${token}`; | |
| 53 | + } else if (spec.authType === "apiKey") { | |
| 54 | + const headerName = spec.headerName ?? String((source as Record<string, unknown>).headerName ?? "x-api-key"); | |
| 55 | + const value = String((source as Record<string, unknown>).value ?? (source as Record<string, unknown>).token ?? "").trim(); | |
| 56 | + if (value) headers[headerName] = value; | |
| 57 | + } else if (spec.authType === "basic") { | |
| 58 | + const username = String((source as Record<string, unknown>).username ?? ""); | |
| 59 | + const password = String((source as Record<string, unknown>).password ?? ""); | |
| 60 | + if (username || password) { | |
| 61 | + const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); | |
| 62 | + headers.authorization = `Basic ${encoded}`; | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + if (Object.keys(headers).length === 0) { | |
| 67 | + const bindingOverrides = asPayload((record?.overridesJson as unknown) ?? {}); | |
| 68 | + const overrideHeaders = asPayload(bindingOverrides.headers); | |
| 69 | + if (Object.keys(overrideHeaders).length === 0) { | |
| 70 | + return null; | |
| 71 | + } | |
| 72 | + for (const [key, value] of Object.entries(overrideHeaders)) { | |
| 73 | + if (!key) continue; | |
| 74 | + headers[key] = String(value); | |
| 75 | + } | |
| 76 | + } else { | |
| 77 | + const bindingOverrides = asPayload((record?.overridesJson as unknown) ?? {}); | |
| 78 | + const overrideHeaders = asPayload(bindingOverrides.headers); | |
| 79 | + for (const [key, value] of Object.entries(overrideHeaders)) { | |
| 80 | + if (!key) continue; | |
| 81 | + headers[key] = String(value); | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + return { | |
| 86 | + sourceKey: spec.sourceKey, | |
| 87 | + mode: spec.mode, | |
| 88 | + headers, | |
| 89 | + }; | |
| 90 | +} | |
| 91 | + | |
| 92 | +function getGraphqlDecision( | |
| 93 | + task: TaskRecord, | |
| 94 | + tool: ToolDefinition, | |
| 95 | + input: unknown, | |
| 96 | + workspaceTools: Map<string, ToolDefinition>, | |
| 97 | + policies: AccessPolicyRecord[], | |
| 98 | +): { decision: PolicyDecision; effectivePaths: string[] } { | |
| 99 | + const sourceName = tool._graphqlSource!; | |
| 100 | + const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {}; | |
| 101 | + const queryString = typeof payload.query === "string" ? payload.query : ""; | |
| 102 | + | |
| 103 | + if (!queryString.trim()) { | |
| 104 | + return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; | |
| 105 | + } | |
| 106 | + | |
| 107 | + const { fieldPaths } = parseGraphqlOperationPaths(sourceName, queryString); | |
| 108 | + if (fieldPaths.length === 0) { | |
| 109 | + return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] }; | |
| 110 | + } | |
| 111 | + | |
| 112 | + let worstDecision: PolicyDecision = "allow"; | |
| 113 | + | |
| 114 | + for (const fieldPath of fieldPaths) { | |
| 115 | + const pseudoTool = workspaceTools.get(fieldPath); | |
| 116 | + const fieldDecision = pseudoTool | |
| 117 | + ? getDecisionForContext( | |
| 118 | + pseudoTool, | |
| 119 | + { | |
| 120 | + workspaceId: task.workspaceId, | |
| 121 | + actorId: task.actorId, | |
| 122 | + clientId: task.clientId, | |
| 123 | + }, | |
| 124 | + policies, | |
| 125 | + ) | |
| 126 | + : getDecisionForContext( | |
| 127 | + { ...tool, path: fieldPath, approval: fieldPath.includes(".mutation.") ? "required" : "auto" }, | |
| 128 | + { | |
| 129 | + workspaceId: task.workspaceId, | |
| 130 | + actorId: task.actorId, | |
| 131 | + clientId: task.clientId, | |
| 132 | + }, | |
| 133 | + policies, | |
| 134 | + ); | |
| 135 | + | |
| 136 | + if (fieldDecision === "deny") { | |
| 137 | + worstDecision = "deny"; | |
| 138 | + break; | |
| 139 | + } | |
| 140 | + if (fieldDecision === "require_approval") { | |
| 141 | + worstDecision = "require_approval"; | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + return { decision: worstDecision, effectivePaths: fieldPaths }; | |
| 146 | +} | |
| 147 | + | |
| 148 | +export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> { | |
| 149 | + const { toolPath, input, callId } = call; | |
| 150 | + const persistedCall = (await ctx.runMutation(internal.database.upsertToolCallRequested, { | |
| 151 | + taskId: task.id, | |
| 152 | + callId, | |
| 153 | + workspaceId: task.workspaceId, | |
| 154 | + toolPath, | |
| 155 | + })) as ToolCallRecord; | |
| 156 | + | |
| 157 | + if (persistedCall.status === "completed") { | |
| 158 | + throw new Error(`Tool call ${callId} already completed; output is not retained`); | |
| 159 | + } | |
| 160 | + | |
| 161 | + if (persistedCall.status === "failed") { | |
| 162 | + throw new Error(persistedCall.error ?? `Tool call failed: ${callId}`); | |
| 163 | + } | |
| 164 | + | |
| 165 | + if (persistedCall.status === "denied") { | |
| 166 | + throw new Error(`${APPROVAL_DENIED_PREFIX}${persistedCall.error ?? persistedCall.toolPath}`); | |
| 167 | + } | |
| 168 | + | |
| 169 | + const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId }); | |
| 170 | + const typedPolicies = policies as AccessPolicyRecord[]; | |
| 171 | + | |
| 172 | + let workspaceTools: Map<string, ToolDefinition> | undefined; | |
| 173 | + let resolvedToolPath = toolPath; | |
| 174 | + let tool = baseTools.get(toolPath); | |
| 175 | + if (!tool) { | |
| 176 | + const result = await getWorkspaceTools(ctx, task.workspaceId); | |
| 177 | + workspaceTools = result.tools; | |
| 178 | + tool = workspaceTools.get(toolPath); | |
| 179 | + | |
| 180 | + if (!tool) { | |
| 181 | + const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools); | |
| 182 | + if (aliasedPath) { | |
| 183 | + resolvedToolPath = aliasedPath; | |
| 184 | + tool = workspaceTools.get(aliasedPath); | |
| 185 | + } | |
| 186 | + } | |
| 187 | + } | |
| 188 | + | |
| 189 | + if (!tool) { | |
| 190 | + const availableTools = workspaceTools ?? baseTools; | |
| 191 | + const suggestions = suggestToolPaths(toolPath, availableTools); | |
| 192 | + const suggestionText = suggestions.length > 0 | |
| 193 | + ? `\nDid you mean: ${suggestions.map((path) => `tools.${path}`).join(", ")}` | |
| 194 | + : ""; | |
| 195 | + throw new Error(`Unknown tool: ${toolPath}${suggestionText}`); | |
| 196 | + } | |
| 197 | + | |
| 198 | + let decision: PolicyDecision; | |
| 199 | + let effectiveToolPath = resolvedToolPath; | |
| 200 | + if (tool._graphqlSource) { | |
| 201 | + if (!workspaceTools) { | |
| 202 | + const result = await getWorkspaceTools(ctx, task.workspaceId); | |
| 203 | + workspaceTools = result.tools; | |
| 204 | + } | |
| 205 | + const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies); | |
| 206 | + decision = result.decision; | |
| 207 | + if (result.effectivePaths.length > 0) { | |
| 208 | + effectiveToolPath = result.effectivePaths.join(", "); | |
| 209 | + } | |
| 210 | + } else { | |
| 211 | + decision = getToolDecision(task, tool, typedPolicies); | |
| 212 | + } | |
| 213 | + | |
| 214 | + const publishToolStarted = persistedCall.status === "requested"; | |
| 215 | + | |
| 216 | + if (decision === "deny") { | |
| 217 | + const deniedMessage = `${effectiveToolPath} (policy denied)`; | |
| 218 | + await ctx.runMutation(internal.database.finishToolCall, { | |
| 219 | + taskId: task.id, | |
| 220 | + callId, | |
| 221 | + status: "denied", | |
| 222 | + error: deniedMessage, | |
| 223 | + }); | |
| 224 | + await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", { | |
| 225 | + taskId: task.id, | |
| 226 | + callId, | |
| 227 | + toolPath: effectiveToolPath, | |
| 228 | + reason: "policy_deny", | |
| 229 | + }); | |
| 230 | + throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); | |
| 231 | + } | |
| 232 | + | |
| 233 | + let credential: ResolvedToolCredential | undefined; | |
| 234 | + if (tool.credential) { | |
| 235 | + const resolved = await resolveCredentialHeaders(ctx, tool.credential, task); | |
| 236 | + if (!resolved) { | |
| 237 | + throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`); | |
| 238 | + } | |
| 239 | + credential = resolved; | |
| 240 | + } | |
| 241 | + | |
| 242 | + if (publishToolStarted) { | |
| 243 | + await publishTaskEvent(ctx, task.id, "task", "tool.call.started", { | |
| 244 | + taskId: task.id, | |
| 245 | + callId, | |
| 246 | + toolPath: effectiveToolPath, | |
| 247 | + approval: decision === "require_approval" ? "required" : "auto", | |
| 248 | + }); | |
| 249 | + } | |
| 250 | + | |
| 251 | + let approvalSatisfied = false; | |
| 252 | + if (persistedCall.approvalId) { | |
| 253 | + const existingApproval = await ctx.runQuery(internal.database.getApproval, { | |
| 254 | + approvalId: persistedCall.approvalId, | |
| 255 | + }); | |
| 256 | + if (!existingApproval) { | |
| 257 | + throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`); | |
| 258 | + } | |
| 259 | + | |
| 260 | + if (existingApproval.status === "pending") { | |
| 261 | + throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`); | |
| 262 | + } | |
| 263 | + | |
| 264 | + if (existingApproval.status === "denied") { | |
| 265 | + const deniedMessage = `${effectiveToolPath} (${existingApproval.id})`; | |
| 266 | + await ctx.runMutation(internal.database.finishToolCall, { | |
| 267 | + taskId: task.id, | |
| 268 | + callId, | |
| 269 | + status: "denied", | |
| 270 | + error: deniedMessage, | |
| 271 | + }); | |
| 272 | + await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", { | |
| 273 | + taskId: task.id, | |
| 274 | + callId, | |
| 275 | + toolPath: effectiveToolPath, | |
| 276 | + approvalId: existingApproval.id, | |
| 277 | + }); | |
| 278 | + throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); | |
| 279 | + } | |
| 280 | + | |
| 281 | + approvalSatisfied = existingApproval.status === "approved"; | |
| 282 | + } | |
| 283 | + | |
| 284 | + if (decision === "require_approval" && !approvalSatisfied) { | |
| 285 | + const approvalId = persistedCall.approvalId ?? createApprovalId(); | |
| 286 | + let approval = await ctx.runQuery(internal.database.getApproval, { | |
| 287 | + approvalId, | |
| 288 | + }); | |
| 289 | + | |
| 290 | + if (!approval) { | |
| 291 | + approval = await ctx.runMutation(internal.database.createApproval, { | |
| 292 | + id: approvalId, | |
| 293 | + taskId: task.id, | |
| 294 | + toolPath: effectiveToolPath, | |
| 295 | + input, | |
| 296 | + }); | |
| 297 | + | |
| 298 | + await publishTaskEvent(ctx, task.id, "approval", "approval.requested", { | |
| 299 | + approvalId: approval.id, | |
| 300 | + taskId: task.id, | |
| 301 | + callId, | |
| 302 | + toolPath: approval.toolPath, | |
| 303 | + input: asPayload(approval.input), | |
| 304 | + createdAt: approval.createdAt, | |
| 305 | + }); | |
| 306 | + } | |
| 307 | + | |
| 308 | + await ctx.runMutation(internal.database.setToolCallPendingApproval, { | |
| 309 | + taskId: task.id, | |
| 310 | + callId, | |
| 311 | + approvalId: approval.id, | |
| 312 | + }); | |
| 313 | + | |
| 314 | + if (approval.status === "pending") { | |
| 315 | + throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`); | |
| 316 | + } | |
| 317 | + | |
| 318 | + if (approval.status === "denied") { | |
| 319 | + const deniedMessage = `${effectiveToolPath} (${approval.id})`; | |
| 320 | + await ctx.runMutation(internal.database.finishToolCall, { | |
| 321 | + taskId: task.id, | |
| 322 | + callId, | |
| 323 | + status: "denied", | |
| 324 | + error: deniedMessage, | |
| 325 | + }); | |
| 326 | + await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", { | |
| 327 | + taskId: task.id, | |
| 328 | + callId, | |
| 329 | + toolPath: effectiveToolPath, | |
| 330 | + approvalId: approval.id, | |
| 331 | + }); | |
| 332 | + throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); | |
| 333 | + } | |
| 334 | + } | |
| 335 | + | |
| 336 | + try { | |
| 337 | + const context: ToolRunContext = { | |
| 338 | + taskId: task.id, | |
| 339 | + workspaceId: task.workspaceId, | |
| 340 | + actorId: task.actorId, | |
| 341 | + clientId: task.clientId, | |
| 342 | + credential, | |
| 343 | + isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies), | |
| 344 | + }; | |
| 345 | + const value = await tool.run(input, context); | |
| 346 | + await ctx.runMutation(internal.database.finishToolCall, { | |
| 347 | + taskId: task.id, | |
| 348 | + callId, | |
| 349 | + status: "completed", | |
| 350 | + }); | |
| 351 | + await publishTaskEvent(ctx, task.id, "task", "tool.call.completed", { | |
| 352 | + taskId: task.id, | |
| 353 | + callId, | |
| 354 | + toolPath: effectiveToolPath, | |
| 355 | + outputRedacted: true, | |
| 356 | + }); | |
| 357 | + return value; | |
| 358 | + } catch (error) { | |
| 359 | + const message = describeError(error); | |
| 360 | + await ctx.runMutation(internal.database.finishToolCall, { | |
| 361 | + taskId: task.id, | |
| 362 | + callId, | |
| 363 | + status: "failed", | |
| 364 | + error: message, | |
| 365 | + }); | |
| 366 | + await publishTaskEvent(ctx, task.id, "task", "tool.call.failed", { | |
| 367 | + taskId: task.id, | |
| 368 | + callId, | |
| 369 | + toolPath: effectiveToolPath, | |
| 370 | + error: message, | |
| 371 | + }); | |
| 372 | + throw error; | |
| 373 | + } | |
| 374 | +} | |
| @@ -0,0 +1,105 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import type { ToolDefinition } from "../../core/src/types"; | |
| 4 | + | |
| 5 | +function normalizeToolPathSegment(segment: string): string { | |
| 6 | + return segment.toLowerCase().replace(/[^a-z0-9]/g, ""); | |
| 7 | +} | |
| 8 | + | |
| 9 | +function normalizeToolPath(path: string): string { | |
| 10 | + return path | |
| 11 | + .split(".") | |
| 12 | + .filter(Boolean) | |
| 13 | + .map((segment) => normalizeToolPathSegment(segment)) | |
| 14 | + .join("."); | |
| 15 | +} | |
| 16 | + | |
| 17 | +function levenshteinDistance(a: string, b: string): number { | |
| 18 | + if (a === b) return 0; | |
| 19 | + if (a.length === 0) return b.length; | |
| 20 | + if (b.length === 0) return a.length; | |
| 21 | + | |
| 22 | + const prev = new Array<number>(b.length + 1); | |
| 23 | + const curr = new Array<number>(b.length + 1); | |
| 24 | + for (let j = 0; j <= b.length; j++) prev[j] = j; | |
| 25 | + | |
| 26 | + for (let i = 1; i <= a.length; i++) { | |
| 27 | + curr[0] = i; | |
| 28 | + for (let j = 1; j <= b.length; j++) { | |
| 29 | + const cost = a[i - 1] === b[j - 1] ? 0 : 1; | |
| 30 | + curr[j] = Math.min( | |
| 31 | + curr[j - 1] + 1, | |
| 32 | + prev[j] + 1, | |
| 33 | + prev[j - 1] + cost, | |
| 34 | + ); | |
| 35 | + } | |
| 36 | + for (let j = 0; j <= b.length; j++) prev[j] = curr[j]; | |
| 37 | + } | |
| 38 | + | |
| 39 | + return prev[b.length] ?? Math.max(a.length, b.length); | |
| 40 | +} | |
| 41 | + | |
| 42 | +export function resolveAliasedToolPath( | |
| 43 | + requestedPath: string, | |
| 44 | + toolMap: Map<string, ToolDefinition>, | |
| 45 | +): string | null { | |
| 46 | + if (toolMap.has(requestedPath)) return requestedPath; | |
| 47 | + | |
| 48 | + const normalizedRequested = normalizeToolPath(requestedPath); | |
| 49 | + if (!normalizedRequested) return null; | |
| 50 | + | |
| 51 | + const matches: string[] = []; | |
| 52 | + for (const path of toolMap.keys()) { | |
| 53 | + if (normalizeToolPath(path) === normalizedRequested) { | |
| 54 | + matches.push(path); | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + if (matches.length === 0) return null; | |
| 59 | + if (matches.length === 1) return matches[0]!; | |
| 60 | + | |
| 61 | + const requestedSegments = requestedPath.split(".").length; | |
| 62 | + const sameSegmentCount = matches.filter((path) => path.split(".").length === requestedSegments); | |
| 63 | + const pool = sameSegmentCount.length > 0 ? sameSegmentCount : matches; | |
| 64 | + return [...pool].sort((a, b) => a.length - b.length || a.localeCompare(b))[0] ?? null; | |
| 65 | +} | |
| 66 | + | |
| 67 | +export function suggestToolPaths( | |
| 68 | + requestedPath: string, | |
| 69 | + toolMap: Map<string, ToolDefinition>, | |
| 70 | + limit = 3, | |
| 71 | +): string[] { | |
| 72 | + const normalizedRequested = normalizeToolPath(requestedPath); | |
| 73 | + const requestedSegments = normalizedRequested.split(".").filter(Boolean); | |
| 74 | + const requestedNamespace = requestedSegments[0] ?? ""; | |
| 75 | + | |
| 76 | + return [...toolMap.keys()] | |
| 77 | + .map((path) => { | |
| 78 | + const normalizedCandidate = normalizeToolPath(path); | |
| 79 | + const candidateSegments = normalizedCandidate.split(".").filter(Boolean); | |
| 80 | + const candidateNamespace = candidateSegments[0] ?? ""; | |
| 81 | + | |
| 82 | + let score = -levenshteinDistance(normalizedRequested, normalizedCandidate); | |
| 83 | + | |
| 84 | + if (requestedNamespace && requestedNamespace === candidateNamespace) { | |
| 85 | + score += 6; | |
| 86 | + } | |
| 87 | + | |
| 88 | + if (normalizedCandidate.includes(normalizedRequested) || normalizedRequested.includes(normalizedCandidate)) { | |
| 89 | + score += 3; | |
| 90 | + } | |
| 91 | + | |
| 92 | + const sharedPrefix = Math.min(requestedSegments.length, candidateSegments.length); | |
| 93 | + let prefixMatches = 0; | |
| 94 | + for (let i = 0; i < sharedPrefix; i++) { | |
| 95 | + if (requestedSegments[i] !== candidateSegments[i]) break; | |
| 96 | + prefixMatches += 1; | |
| 97 | + } | |
| 98 | + score += prefixMatches * 2; | |
| 99 | + | |
| 100 | + return { path, score }; | |
| 101 | + }) | |
| 102 | + .sort((a, b) => b.score - a.score) | |
| 103 | + .slice(0, limit) | |
| 104 | + .map((item) => item.path); | |
| 105 | +} | |
| @@ -0,0 +1,194 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import type { ActionCtx } from "../_generated/server"; | |
| 4 | +import { internal } from "../_generated/api"; | |
| 5 | +import { | |
| 6 | + compileExternalToolSource, | |
| 7 | + compileOpenApiToolSourceFromPrepared, | |
| 8 | + prepareOpenApiSpec, | |
| 9 | + type CompiledToolSourceArtifact, | |
| 10 | + type ExternalToolSourceConfig, | |
| 11 | + type GraphqlToolSourceConfig, | |
| 12 | + type McpToolSourceConfig, | |
| 13 | + type OpenApiToolSourceConfig, | |
| 14 | + type PreparedOpenApiSpec, | |
| 15 | +} from "../../core/src/tool-sources"; | |
| 16 | +import type { ToolSourceRecord } from "../../core/src/types"; | |
| 17 | + | |
| 18 | +const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; | |
| 19 | + | |
| 20 | +/** Cache version - bump when PreparedOpenApiSpec shape changes. */ | |
| 21 | +const OPENAPI_CACHE_VERSION = "v15"; | |
| 22 | + | |
| 23 | +export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { | |
| 24 | + const signatureVersion = "v15"; | |
| 25 | + const parts = sources | |
| 26 | + .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`) | |
| 27 | + .sort(); | |
| 28 | + return `${signatureVersion}|${workspaceId}|${parts.join(",")}`; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function normalizeExternalToolSource(raw: { | |
| 32 | + id: string; | |
| 33 | + type: ToolSourceRecord["type"]; | |
| 34 | + name: string; | |
| 35 | + config: Record<string, unknown>; | |
| 36 | +}): ExternalToolSourceConfig { | |
| 37 | + const config = raw.config; | |
| 38 | + | |
| 39 | + if (raw.type === "mcp") { | |
| 40 | + if (typeof config.url !== "string" || config.url.trim().length === 0) { | |
| 41 | + throw new Error(`MCP source '${raw.name}' missing url`); | |
| 42 | + } | |
| 43 | + | |
| 44 | + if ( | |
| 45 | + config.transport !== undefined | |
| 46 | + && config.transport !== "sse" | |
| 47 | + && config.transport !== "streamable-http" | |
| 48 | + ) { | |
| 49 | + throw new Error(`MCP source '${raw.name}' has invalid transport`); | |
| 50 | + } | |
| 51 | + | |
| 52 | + if (config.queryParams !== undefined) { | |
| 53 | + const queryParams = config.queryParams; | |
| 54 | + if (!queryParams || typeof queryParams !== "object" || Array.isArray(queryParams)) { | |
| 55 | + throw new Error(`MCP source '${raw.name}' queryParams must be an object`); | |
| 56 | + } | |
| 57 | + | |
| 58 | + for (const value of Object.values(queryParams as Record<string, unknown>)) { | |
| 59 | + if (typeof value !== "string") { | |
| 60 | + throw new Error(`MCP source '${raw.name}' queryParams values must be strings`); | |
| 61 | + } | |
| 62 | + } | |
| 63 | + } | |
| 64 | + | |
| 65 | + const result: McpToolSourceConfig = { | |
| 66 | + type: "mcp", | |
| 67 | + name: raw.name, | |
| 68 | + sourceId: raw.id, | |
| 69 | + sourceKey: `source:${raw.id}`, | |
| 70 | + url: config.url, | |
| 71 | + transport: config.transport as McpToolSourceConfig["transport"], | |
| 72 | + queryParams: config.queryParams as McpToolSourceConfig["queryParams"], | |
| 73 | + defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"], | |
| 74 | + overrides: config.overrides as McpToolSourceConfig["overrides"], | |
| 75 | + }; | |
| 76 | + return result; | |
| 77 | + } | |
| 78 | + | |
| 79 | + if (raw.type === "graphql") { | |
| 80 | + if (typeof config.endpoint !== "string" || config.endpoint.trim().length === 0) { | |
| 81 | + throw new Error(`GraphQL source '${raw.name}' missing endpoint`); | |
| 82 | + } | |
| 83 | + | |
| 84 | + const result: GraphqlToolSourceConfig = { | |
| 85 | + type: "graphql", | |
| 86 | + name: raw.name, | |
| 87 | + sourceId: raw.id, | |
| 88 | + sourceKey: `source:${raw.id}`, | |
| 89 | + endpoint: config.endpoint, | |
| 90 | + schema: config.schema as GraphqlToolSourceConfig["schema"], | |
| 91 | + auth: config.auth as GraphqlToolSourceConfig["auth"], | |
| 92 | + defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"], | |
| 93 | + defaultMutationApproval: config.defaultMutationApproval as GraphqlToolSourceConfig["defaultMutationApproval"], | |
| 94 | + overrides: config.overrides as GraphqlToolSourceConfig["overrides"], | |
| 95 | + }; | |
| 96 | + return result; | |
| 97 | + } | |
| 98 | + | |
| 99 | + const spec = config.spec; | |
| 100 | + if (typeof spec !== "string" && (typeof spec !== "object" || spec === null)) { | |
| 101 | + throw new Error(`OpenAPI source '${raw.name}' missing spec`); | |
| 102 | + } | |
| 103 | + | |
| 104 | + const result: OpenApiToolSourceConfig = { | |
| 105 | + type: "openapi", | |
| 106 | + name: raw.name, | |
| 107 | + sourceId: raw.id, | |
| 108 | + sourceKey: `source:${raw.id}`, | |
| 109 | + spec: spec as OpenApiToolSourceConfig["spec"], | |
| 110 | + baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"], | |
| 111 | + auth: config.auth as OpenApiToolSourceConfig["auth"], | |
| 112 | + defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"], | |
| 113 | + defaultWriteApproval: config.defaultWriteApproval as OpenApiToolSourceConfig["defaultWriteApproval"], | |
| 114 | + overrides: config.overrides as OpenApiToolSourceConfig["overrides"], | |
| 115 | + }; | |
| 116 | + return result; | |
| 117 | +} | |
| 118 | + | |
| 119 | +async function loadCachedOpenApiSpec( | |
| 120 | + ctx: ActionCtx, | |
| 121 | + specUrl: string, | |
| 122 | + sourceName: string, | |
| 123 | +): Promise<PreparedOpenApiSpec> { | |
| 124 | + try { | |
| 125 | + const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, { | |
| 126 | + specUrl, | |
| 127 | + version: OPENAPI_CACHE_VERSION, | |
| 128 | + maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS, | |
| 129 | + }); | |
| 130 | + | |
| 131 | + if (entry) { | |
| 132 | + const blob = await ctx.storage.get(entry.storageId); | |
| 133 | + if (blob) { | |
| 134 | + const json = await blob.text(); | |
| 135 | + return JSON.parse(json) as PreparedOpenApiSpec; | |
| 136 | + } | |
| 137 | + } | |
| 138 | + } catch (error) { | |
| 139 | + const message = error instanceof Error ? error.message : String(error); | |
| 140 | + console.warn(`[executor] OpenAPI cache read failed for '${sourceName}': ${message}`); | |
| 141 | + } | |
| 142 | + | |
| 143 | + const prepared = await prepareOpenApiSpec(specUrl, sourceName); | |
| 144 | + | |
| 145 | + try { | |
| 146 | + const json = JSON.stringify(prepared); | |
| 147 | + const blob = new Blob([json], { type: "application/json" }); | |
| 148 | + const storageId = await ctx.storage.store(blob); | |
| 149 | + await ctx.runMutation(internal.openApiSpecCache.putEntry, { | |
| 150 | + specUrl, | |
| 151 | + version: OPENAPI_CACHE_VERSION, | |
| 152 | + storageId, | |
| 153 | + sizeBytes: json.length, | |
| 154 | + }); | |
| 155 | + } catch (error) { | |
| 156 | + const message = error instanceof Error ? error.message : String(error); | |
| 157 | + console.warn(`[executor] OpenAPI cache write failed for '${sourceName}': ${message}`); | |
| 158 | + } | |
| 159 | + | |
| 160 | + return prepared; | |
| 161 | +} | |
| 162 | + | |
| 163 | +export async function loadSourceArtifact( | |
| 164 | + ctx: ActionCtx, | |
| 165 | + source: ExternalToolSourceConfig, | |
| 166 | +): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> { | |
| 167 | + if (source.type === "openapi" && typeof source.spec === "string") { | |
| 168 | + try { | |
| 169 | + const prepared = await loadCachedOpenApiSpec(ctx, source.spec, source.name); | |
| 170 | + const artifact = compileOpenApiToolSourceFromPrepared(source, prepared); | |
| 171 | + const warnings = (prepared.warnings ?? []).map( | |
| 172 | + (warning) => `Source '${source.name}': ${warning}`, | |
| 173 | + ); | |
| 174 | + return { artifact, warnings }; | |
| 175 | + } catch (error) { | |
| 176 | + const message = error instanceof Error ? error.message : String(error); | |
| 177 | + return { | |
| 178 | + artifact: undefined, | |
| 179 | + warnings: [`Failed to load openapi source '${source.name}': ${message}`], | |
| 180 | + }; | |
| 181 | + } | |
| 182 | + } | |
| 183 | + | |
| 184 | + try { | |
| 185 | + const artifact = await compileExternalToolSource(source); | |
| 186 | + return { artifact, warnings: [] }; | |
| 187 | + } catch (error) { | |
| 188 | + const message = error instanceof Error ? error.message : String(error); | |
| 189 | + return { | |
| 190 | + artifact: undefined, | |
| 191 | + warnings: [`Failed to load ${source.type} source '${source.name}': ${message}`], | |
| 192 | + }; | |
| 193 | + } | |
| 194 | +} | |
| @@ -0,0 +1,304 @@ | ||
| 1 | +"use node"; | |
| 2 | + | |
| 3 | +import type { ActionCtx } from "../_generated/server"; | |
| 4 | +import { internal } from "../_generated/api"; | |
| 5 | +import type { Id } from "../_generated/dataModel"; | |
| 6 | +import { createDiscoverTool } from "../../core/src/tool-discovery"; | |
| 7 | +import { | |
| 8 | + materializeCompiledToolSource, | |
| 9 | + materializeWorkspaceSnapshot, | |
| 10 | + type CompiledToolSourceArtifact, | |
| 11 | + type ExternalToolSourceConfig, | |
| 12 | + type WorkspaceToolSnapshot, | |
| 13 | +} from "../../core/src/tool-sources"; | |
| 14 | +import { DEFAULT_TOOLS } from "../../core/src/tools"; | |
| 15 | +import type { | |
| 16 | + AccessPolicyRecord, | |
| 17 | + OpenApiSourceQuality, | |
| 18 | + SourceAuthProfile, | |
| 19 | + ToolDefinition, | |
| 20 | + ToolDescriptor, | |
| 21 | +} from "../../core/src/types"; | |
| 22 | +import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors"; | |
| 23 | +import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading"; | |
| 24 | + | |
| 25 | +const baseTools = new Map<string, ToolDefinition>(DEFAULT_TOOLS.map((tool) => [tool.path, tool])); | |
| 26 | + | |
| 27 | +export interface DtsStorageEntry { | |
| 28 | + sourceKey: string; | |
| 29 | + storageId: Id<"_storage">; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export interface WorkspaceToolsResult { | |
| 33 | + tools: Map<string, ToolDefinition>; | |
| 34 | + warnings: string[]; | |
| 35 | + dtsStorageIds: DtsStorageEntry[]; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export interface WorkspaceToolInventory { | |
| 39 | + tools: ToolDescriptor[]; | |
| 40 | + warnings: string[]; | |
| 41 | + dtsStorageIds: DtsStorageEntry[]; | |
| 42 | + sourceQuality: Record<string, OpenApiSourceQuality>; | |
| 43 | + sourceAuthProfiles: Record<string, SourceAuthProfile>; | |
| 44 | +} | |
| 45 | + | |
| 46 | +function computeSourceAuthProfiles(tools: Map<string, ToolDefinition>): Record<string, SourceAuthProfile> { | |
| 47 | + const profiles: Record<string, SourceAuthProfile> = {}; | |
| 48 | + | |
| 49 | + for (const tool of tools.values()) { | |
| 50 | + const credential = tool.credential; | |
| 51 | + if (!credential) continue; | |
| 52 | + | |
| 53 | + const sourceKey = credential.sourceKey; | |
| 54 | + const current = profiles[sourceKey]; | |
| 55 | + if (!current) { | |
| 56 | + profiles[sourceKey] = { | |
| 57 | + type: credential.authType, | |
| 58 | + mode: credential.mode, | |
| 59 | + ...(credential.authType === "apiKey" && credential.headerName | |
| 60 | + ? { header: credential.headerName } | |
| 61 | + : {}), | |
| 62 | + inferred: true, | |
| 63 | + }; | |
| 64 | + continue; | |
| 65 | + } | |
| 66 | + | |
| 67 | + if (current.type !== credential.authType || current.mode !== credential.mode) { | |
| 68 | + profiles[sourceKey] = { | |
| 69 | + type: "mixed", | |
| 70 | + inferred: true, | |
| 71 | + }; | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + return profiles; | |
| 76 | +} | |
| 77 | + | |
| 78 | +export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspaces">): Promise<WorkspaceToolsResult> { | |
| 79 | + const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId })) | |
| 80 | + .filter((source: { enabled: boolean }) => source.enabled); | |
| 81 | + const signature = sourceSignature(workspaceId, sources); | |
| 82 | + | |
| 83 | + try { | |
| 84 | + const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, { | |
| 85 | + workspaceId, | |
| 86 | + signature, | |
| 87 | + }); | |
| 88 | + | |
| 89 | + if (cacheEntry) { | |
| 90 | + const blob = await ctx.storage.get(cacheEntry.storageId); | |
| 91 | + if (blob) { | |
| 92 | + const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot; | |
| 93 | + const restored = materializeWorkspaceSnapshot(snapshot); | |
| 94 | + | |
| 95 | + const merged = new Map<string, ToolDefinition>(); | |
| 96 | + for (const tool of baseTools.values()) { | |
| 97 | + if (tool.path === "discover") continue; | |
| 98 | + merged.set(tool.path, tool); | |
| 99 | + } | |
| 100 | + for (const tool of restored) { | |
| 101 | + if (tool.path === "discover") continue; | |
| 102 | + merged.set(tool.path, tool); | |
| 103 | + } | |
| 104 | + const discover = createDiscoverTool([...merged.values()]); | |
| 105 | + merged.set(discover.path, discover); | |
| 106 | + | |
| 107 | + const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[]; | |
| 108 | + | |
| 109 | + return { tools: merged, warnings: snapshot.warnings, dtsStorageIds }; | |
| 110 | + } | |
| 111 | + } | |
| 112 | + } catch (error) { | |
| 113 | + const msg = error instanceof Error ? error.message : String(error); | |
| 114 | + console.warn(`[executor] workspace tool cache read failed for '${workspaceId}': ${msg}`); | |
| 115 | + } | |
| 116 | + | |
| 117 | + const configs: ExternalToolSourceConfig[] = []; | |
| 118 | + const warnings: string[] = []; | |
| 119 | + for (const source of sources) { | |
| 120 | + try { | |
| 121 | + configs.push(normalizeExternalToolSource(source)); | |
| 122 | + } catch (error) { | |
| 123 | + const message = error instanceof Error ? error.message : String(error); | |
| 124 | + warnings.push(`Source '${source.name}': ${message}`); | |
| 125 | + } | |
| 126 | + } | |
| 127 | + | |
| 128 | + const loadedSources = await Promise.all(configs.map((config) => loadSourceArtifact(ctx, config))); | |
| 129 | + const externalArtifacts = loadedSources | |
| 130 | + .map((loaded) => loaded.artifact) | |
| 131 | + .filter((artifact): artifact is CompiledToolSourceArtifact => Boolean(artifact)); | |
| 132 | + const externalTools = externalArtifacts.flatMap((artifact) => materializeCompiledToolSource(artifact)); | |
| 133 | + warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings)); | |
| 134 | + | |
| 135 | + const merged = new Map<string, ToolDefinition>(); | |
| 136 | + for (const tool of baseTools.values()) { | |
| 137 | + if (tool.path === "discover") continue; | |
| 138 | + merged.set(tool.path, tool); | |
| 139 | + } | |
| 140 | + for (const tool of externalTools) { | |
| 141 | + merged.set(tool.path, tool); | |
| 142 | + } | |
| 143 | + | |
| 144 | + const discover = createDiscoverTool([...merged.values()]); | |
| 145 | + merged.set(discover.path, discover); | |
| 146 | + | |
| 147 | + let dtsStorageIds: DtsStorageEntry[] = []; | |
| 148 | + try { | |
| 149 | + const allTools = [...merged.values()]; | |
| 150 | + | |
| 151 | + const seenDtsSources = new Set<string>(); | |
| 152 | + const dtsEntries: { sourceKey: string; content: string }[] = []; | |
| 153 | + for (const artifact of externalArtifacts) { | |
| 154 | + for (const tool of artifact.tools) { | |
| 155 | + if (tool.metadata?.sourceDts && tool.source && !seenDtsSources.has(tool.source)) { | |
| 156 | + seenDtsSources.add(tool.source); | |
| 157 | + dtsEntries.push({ sourceKey: tool.source, content: tool.metadata.sourceDts }); | |
| 158 | + } | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + const storedDts = await Promise.all( | |
| 163 | + dtsEntries.map(async (entry) => { | |
| 164 | + const dtsBlob = new Blob([entry.content], { type: "text/plain" }); | |
| 165 | + const sid = await ctx.storage.store(dtsBlob); | |
| 166 | + return { sourceKey: entry.sourceKey, storageId: sid }; | |
| 167 | + }), | |
| 168 | + ); | |
| 169 | + dtsStorageIds = storedDts; | |
| 170 | + | |
| 171 | + const sanitizedArtifacts: CompiledToolSourceArtifact[] = externalArtifacts.map((artifact) => ({ | |
| 172 | + ...artifact, | |
| 173 | + tools: artifact.tools.map((tool) => { | |
| 174 | + if (!tool.metadata?.sourceDts) return tool; | |
| 175 | + const metadata = { ...tool.metadata }; | |
| 176 | + delete (metadata as Record<string, unknown>).sourceDts; | |
| 177 | + return { ...tool, metadata }; | |
| 178 | + }), | |
| 179 | + })); | |
| 180 | + | |
| 181 | + const snapshot: WorkspaceToolSnapshot = { | |
| 182 | + version: "v2", | |
| 183 | + externalArtifacts: sanitizedArtifacts, | |
| 184 | + warnings, | |
| 185 | + }; | |
| 186 | + | |
| 187 | + const json = JSON.stringify(snapshot); | |
| 188 | + const blob = new Blob([json], { type: "application/json" }); | |
| 189 | + const storageId = await ctx.storage.store(blob); | |
| 190 | + await ctx.runMutation(internal.workspaceToolCache.putEntry, { | |
| 191 | + workspaceId, | |
| 192 | + signature, | |
| 193 | + storageId, | |
| 194 | + dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })), | |
| 195 | + toolCount: allTools.length, | |
| 196 | + sizeBytes: json.length, | |
| 197 | + }); | |
| 198 | + } catch (error) { | |
| 199 | + const msg = error instanceof Error ? error.message : String(error); | |
| 200 | + console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`); | |
| 201 | + } | |
| 202 | + | |
| 203 | + return { tools: merged, warnings, dtsStorageIds }; | |
| 204 | +} | |
| 205 | + | |
| 206 | +export async function loadWorkspaceToolInventoryForContext( | |
| 207 | + ctx: ActionCtx, | |
| 208 | + context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }, | |
| 209 | +): Promise<WorkspaceToolInventory> { | |
| 210 | + const [result, policies] = await Promise.all([ | |
| 211 | + getWorkspaceTools(ctx, context.workspaceId), | |
| 212 | + ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }), | |
| 213 | + ]); | |
| 214 | + const typedPolicies = policies as AccessPolicyRecord[]; | |
| 215 | + const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies); | |
| 216 | + const sourceQuality = computeOpenApiSourceQuality(result.tools); | |
| 217 | + const sourceAuthProfiles = computeSourceAuthProfiles(result.tools); | |
| 218 | + | |
| 219 | + return { | |
| 220 | + tools, | |
| 221 | + warnings: result.warnings, | |
| 222 | + dtsStorageIds: result.dtsStorageIds, | |
| 223 | + sourceQuality, | |
| 224 | + sourceAuthProfiles, | |
| 225 | + }; | |
| 226 | +} | |
| 227 | + | |
| 228 | +export async function listToolsForContext( | |
| 229 | + ctx: ActionCtx, | |
| 230 | + context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }, | |
| 231 | +): Promise<ToolDescriptor[]> { | |
| 232 | + const inventory = await loadWorkspaceToolInventoryForContext(ctx, context); | |
| 233 | + return inventory.tools; | |
| 234 | +} | |
| 235 | + | |
| 236 | +export async function listToolsWithWarningsForContext( | |
| 237 | + ctx: ActionCtx, | |
| 238 | + context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }, | |
| 239 | +): Promise<{ | |
| 240 | + tools: ToolDescriptor[]; | |
| 241 | + warnings: string[]; | |
| 242 | + dtsUrls: Record<string, string>; | |
| 243 | + sourceQuality: Record<string, OpenApiSourceQuality>; | |
| 244 | + sourceAuthProfiles: Record<string, SourceAuthProfile>; | |
| 245 | +}> { | |
| 246 | + const inventory = await loadWorkspaceToolInventoryForContext(ctx, context); | |
| 247 | + const dtsUrls = await loadDtsUrls(ctx, inventory.dtsStorageIds); | |
| 248 | + | |
| 249 | + return { | |
| 250 | + tools: inventory.tools, | |
| 251 | + warnings: inventory.warnings, | |
| 252 | + dtsUrls, | |
| 253 | + sourceQuality: inventory.sourceQuality, | |
| 254 | + sourceAuthProfiles: inventory.sourceAuthProfiles, | |
| 255 | + }; | |
| 256 | +} | |
| 257 | + | |
| 258 | +export async function loadDtsUrls(ctx: ActionCtx, entries: DtsStorageEntry[]): Promise<Record<string, string>> { | |
| 259 | + if (entries.length === 0) return {}; | |
| 260 | + | |
| 261 | + const urlEntries = await Promise.all(entries.map(async (entry) => { | |
| 262 | + try { | |
| 263 | + const url = await ctx.storage.getUrl(entry.storageId); | |
| 264 | + return url ? [entry.sourceKey, url] as const : null; | |
| 265 | + } catch { | |
| 266 | + return null; | |
| 267 | + } | |
| 268 | + })); | |
| 269 | + | |
| 270 | + const dtsUrls: Record<string, string> = {}; | |
| 271 | + for (const pair of urlEntries) { | |
| 272 | + if (!pair) continue; | |
| 273 | + const [sourceKey, url] = pair; | |
| 274 | + dtsUrls[sourceKey] = url; | |
| 275 | + } | |
| 276 | + | |
| 277 | + return dtsUrls; | |
| 278 | +} | |
| 279 | + | |
| 280 | +export async function loadWorkspaceDtsStorageIds( | |
| 281 | + ctx: ActionCtx, | |
| 282 | + workspaceId: Id<"workspaces">, | |
| 283 | +): Promise<DtsStorageEntry[]> { | |
| 284 | + const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId })) | |
| 285 | + .filter((source: { enabled: boolean }) => source.enabled); | |
| 286 | + const signature = sourceSignature(workspaceId, sources); | |
| 287 | + | |
| 288 | + try { | |
| 289 | + const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, { | |
| 290 | + workspaceId, | |
| 291 | + signature, | |
| 292 | + }); | |
| 293 | + if (cacheEntry) { | |
| 294 | + return (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[]; | |
| 295 | + } | |
| 296 | + } catch { | |
| 297 | + // Fall through to a full rebuild path. | |
| 298 | + } | |
| 299 | + | |
| 300 | + const rebuilt = await getWorkspaceTools(ctx, workspaceId); | |
| 301 | + return rebuilt.dtsStorageIds; | |
| 302 | +} | |
| 303 | + | |
| 304 | +export { baseTools }; | |
| @@ -0,0 +1,199 @@ | ||
| 1 | +import { expect, test } from "bun:test"; | |
| 2 | +import { convexTest } from "convex-test"; | |
| 3 | +import { internal } from "./_generated/api"; | |
| 4 | +import schema from "./schema"; | |
| 5 | +import { computeS256Challenge } from "../core/src/anonymous-oauth"; | |
| 6 | + | |
| 7 | +function setup() { | |
| 8 | + return convexTest(schema, { | |
| 9 | + "./database.ts": () => import("./database"), | |
| 10 | + "./executor.ts": () => import("./executor"), | |
| 11 | + "./executorNode.ts": () => import("./executorNode"), | |
| 12 | + "./http.ts": () => import("./http"), | |
| 13 | + "./auth.ts": () => import("./auth"), | |
| 14 | + "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), | |
| 15 | + "./workspaceToolCache.ts": () => import("./workspaceToolCache"), | |
| 16 | + "./openApiSpecCache.ts": () => import("./openApiSpecCache"), | |
| 17 | + "./mcpNode.ts": () => import("./mcpNode"), | |
| 18 | + "./anonymousOauth.ts": () => import("./anonymousOauth"), | |
| 19 | + "./_generated/api.js": () => import("./_generated/api.js"), | |
| 20 | + }); | |
| 21 | +} | |
| 22 | + | |
| 23 | +function generateCodeVerifier(): string { | |
| 24 | + const bytes = new Uint8Array(32); | |
| 25 | + crypto.getRandomValues(bytes); | |
| 26 | + let binary = ""; | |
| 27 | + for (const b of bytes) { | |
| 28 | + binary += String.fromCharCode(b); | |
| 29 | + } | |
| 30 | + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | |
| 31 | +} | |
| 32 | + | |
| 33 | +async function fetchViaConvexTest( | |
| 34 | + t: ReturnType<typeof setup>, | |
| 35 | + url: string, | |
| 36 | + init?: RequestInit, | |
| 37 | +) { | |
| 38 | + const parsed = new URL(url); | |
| 39 | + return await t.fetch(`${parsed.pathname}${parsed.search}${parsed.hash}`, init); | |
| 40 | +} | |
| 41 | + | |
| 42 | +test("anonymous OAuth flow binds token to workspace/session for MCP", async () => { | |
| 43 | + const previousAnonymousOauth = process.env.MCP_ENABLE_ANONYMOUS_OAUTH; | |
| 44 | + const previousAuthorizationServer = process.env.MCP_AUTHORIZATION_SERVER; | |
| 45 | + process.env.MCP_ENABLE_ANONYMOUS_OAUTH = "1"; | |
| 46 | + delete process.env.MCP_AUTHORIZATION_SERVER; | |
| 47 | + | |
| 48 | + const t = setup(); | |
| 49 | + | |
| 50 | + try { | |
| 51 | + const session = await t.mutation(internal.database.bootstrapAnonymousSession, {}); | |
| 52 | + const query = new URLSearchParams({ | |
| 53 | + workspaceId: session.workspaceId, | |
| 54 | + sessionId: session.sessionId, | |
| 55 | + }).toString(); | |
| 56 | + | |
| 57 | + const protectedResourceRes = await t.fetch(`/.well-known/oauth-protected-resource?${query}`); | |
| 58 | + expect(protectedResourceRes.status).toBe(200); | |
| 59 | + const protectedResource = await protectedResourceRes.json() as { | |
| 60 | + resource: string; | |
| 61 | + authorization_servers: string[]; | |
| 62 | + }; | |
| 63 | + const protectedResourceUrl = new URL(protectedResource.resource); | |
| 64 | + expect(protectedResourceUrl.pathname).toBe("/mcp"); | |
| 65 | + expect(protectedResourceUrl.searchParams.get("workspaceId")).toBe(session.workspaceId); | |
| 66 | + expect(protectedResourceUrl.searchParams.get("sessionId")).toBe(session.sessionId); | |
| 67 | + expect(protectedResource.authorization_servers.length).toBe(1); | |
| 68 | + | |
| 69 | + const authorizationServerUrl = protectedResource.authorization_servers[0]!; | |
| 70 | + const metadataWithoutQueryRes = await fetchViaConvexTest( | |
| 71 | + t, | |
| 72 | + `${authorizationServerUrl}/.well-known/oauth-authorization-server`, | |
| 73 | + ); | |
| 74 | + expect(metadataWithoutQueryRes.status).toBe(200); | |
| 75 | + const metadataWithoutQuery = await metadataWithoutQueryRes.json() as { | |
| 76 | + issuer: string; | |
| 77 | + }; | |
| 78 | + expect(metadataWithoutQuery.issuer).toBe(authorizationServerUrl); | |
| 79 | + | |
| 80 | + const metadataRes = await fetchViaConvexTest( | |
| 81 | + t, | |
| 82 | + `${authorizationServerUrl}/.well-known/oauth-authorization-server?${query}`, | |
| 83 | + ); | |
| 84 | + expect(metadataRes.status).toBe(200); | |
| 85 | + const metadata = await metadataRes.json() as { | |
| 86 | + registration_endpoint: string; | |
| 87 | + authorization_endpoint: string; | |
| 88 | + token_endpoint: string; | |
| 89 | + }; | |
| 90 | + | |
| 91 | + const registerRes = await fetchViaConvexTest(t, metadata.registration_endpoint, { | |
| 92 | + method: "POST", | |
| 93 | + headers: { "content-type": "application/json" }, | |
| 94 | + body: JSON.stringify({ | |
| 95 | + redirect_uris: ["http://localhost:9999/callback"], | |
| 96 | + }), | |
| 97 | + }); | |
| 98 | + expect(registerRes.status).toBe(201); | |
| 99 | + const registration = await registerRes.json() as { client_id: string }; | |
| 100 | + | |
| 101 | + const codeVerifier = generateCodeVerifier(); | |
| 102 | + const codeChallenge = await computeS256Challenge(codeVerifier); | |
| 103 | + const authorizeUrl = new URL(metadata.authorization_endpoint); | |
| 104 | + authorizeUrl.searchParams.set("response_type", "code"); | |
| 105 | + authorizeUrl.searchParams.set("client_id", registration.client_id); | |
| 106 | + authorizeUrl.searchParams.set("redirect_uri", "http://localhost:9999/callback"); | |
| 107 | + authorizeUrl.searchParams.set("code_challenge", codeChallenge); | |
| 108 | + authorizeUrl.searchParams.set("code_challenge_method", "S256"); | |
| 109 | + authorizeUrl.searchParams.set("resource", protectedResource.resource); | |
| 110 | + | |
| 111 | + const authorizeRes = await fetchViaConvexTest(t, authorizeUrl.toString(), { redirect: "manual" }); | |
| 112 | + expect(authorizeRes.status).toBe(302); | |
| 113 | + const redirectLocation = authorizeRes.headers.get("location"); | |
| 114 | + expect(redirectLocation).toBeTruthy(); | |
| 115 | + const code = new URL(redirectLocation!).searchParams.get("code"); | |
| 116 | + expect(code).toBeTruthy(); | |
| 117 | + | |
| 118 | + const tokenRes = await fetchViaConvexTest(t, metadata.token_endpoint, { | |
| 119 | + method: "POST", | |
| 120 | + headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 121 | + body: new URLSearchParams({ | |
| 122 | + grant_type: "authorization_code", | |
| 123 | + code: code!, | |
| 124 | + client_id: registration.client_id, | |
| 125 | + redirect_uri: "http://localhost:9999/callback", | |
| 126 | + code_verifier: codeVerifier, | |
| 127 | + }).toString(), | |
| 128 | + }); | |
| 129 | + expect(tokenRes.status).toBe(200); | |
| 130 | + const tokenBody = await tokenRes.json() as { access_token: string }; | |
| 131 | + expect(tokenBody.access_token).toBeTruthy(); | |
| 132 | + | |
| 133 | + const authorizedMcpRes = await t.fetch(`/mcp?${query}`, { | |
| 134 | + method: "POST", | |
| 135 | + headers: { | |
| 136 | + authorization: `Bearer ${tokenBody.access_token}`, | |
| 137 | + "content-type": "application/json", | |
| 138 | + }, | |
| 139 | + body: JSON.stringify({}), | |
| 140 | + }); | |
| 141 | + expect(authorizedMcpRes.status).not.toBe(401); | |
| 142 | + expect(authorizedMcpRes.status).not.toBe(403); | |
| 143 | + | |
| 144 | + const mismatchedQuery = new URLSearchParams({ | |
| 145 | + workspaceId: session.workspaceId, | |
| 146 | + sessionId: `mcp_${crypto.randomUUID()}`, | |
| 147 | + }).toString(); | |
| 148 | + const mismatchedMcpRes = await t.fetch(`/mcp?${mismatchedQuery}`, { | |
| 149 | + method: "POST", | |
| 150 | + headers: { | |
| 151 | + authorization: `Bearer ${tokenBody.access_token}`, | |
| 152 | + "content-type": "application/json", | |
| 153 | + }, | |
| 154 | + body: JSON.stringify({}), | |
| 155 | + }); | |
| 156 | + expect(mismatchedMcpRes.status).toBe(401); | |
| 157 | + } finally { | |
| 158 | + if (previousAnonymousOauth === undefined) { | |
| 159 | + delete process.env.MCP_ENABLE_ANONYMOUS_OAUTH; | |
| 160 | + } else { | |
| 161 | + process.env.MCP_ENABLE_ANONYMOUS_OAUTH = previousAnonymousOauth; | |
| 162 | + } | |
| 163 | + | |
| 164 | + if (previousAuthorizationServer === undefined) { | |
| 165 | + delete process.env.MCP_AUTHORIZATION_SERVER; | |
| 166 | + } else { | |
| 167 | + process.env.MCP_AUTHORIZATION_SERVER = previousAuthorizationServer; | |
| 168 | + } | |
| 169 | + } | |
| 170 | +}); | |
| 171 | + | |
| 172 | +test("queryless protected-resource discovery prefers anonymous auth metadata", async () => { | |
| 173 | + const previousAnonymousOauth = process.env.MCP_ENABLE_ANONYMOUS_OAUTH; | |
| 174 | + const previousAuthorizationServer = process.env.MCP_AUTHORIZATION_SERVER; | |
| 175 | + process.env.MCP_ENABLE_ANONYMOUS_OAUTH = "1"; | |
| 176 | + process.env.MCP_AUTHORIZATION_SERVER = "https://victorious-point-35-staging.authkit.app"; | |
| 177 | + | |
| 178 | + const t = setup(); | |
| 179 | + try { | |
| 180 | + const res = await t.fetch("/.well-known/oauth-protected-resource"); | |
| 181 | + expect(res.status).toBe(200); | |
| 182 | + const body = await res.json() as { | |
| 183 | + authorization_servers: string[]; | |
| 184 | + }; | |
| 185 | + expect(body.authorization_servers[0]).toBe("https://some.convex.site"); | |
| 186 | + } finally { | |
| 187 | + if (previousAnonymousOauth === undefined) { | |
| 188 | + delete process.env.MCP_ENABLE_ANONYMOUS_OAUTH; | |
| 189 | + } else { | |
| 190 | + process.env.MCP_ENABLE_ANONYMOUS_OAUTH = previousAnonymousOauth; | |
| 191 | + } | |
| 192 | + | |
| 193 | + if (previousAuthorizationServer === undefined) { | |
| 194 | + delete process.env.MCP_AUTHORIZATION_SERVER; | |
| 195 | + } else { | |
| 196 | + process.env.MCP_AUTHORIZATION_SERVER = previousAuthorizationServer; | |
| 197 | + } | |
| 198 | + } | |
| 199 | +}); | |
| @@ -1,295 +1,19 @@ | ||
| 1 | 1 | import { registerRoutes as registerStripeRoutes } from "@convex-dev/stripe"; |
| 2 | −import { createRemoteJWKSet, jwtVerify } from "jose"; | |
| 3 | 2 | import { httpRouter } from "convex/server"; |
| 4 | −import { components, internal } from "./_generated/api"; | |
| 5 | −import { httpAction } from "./_generated/server"; | |
| 3 | +import { components } from "./_generated/api"; | |
| 6 | 4 | import { authKit } from "./auth"; |
| 7 | −import { handleMcpRequest, type McpWorkspaceContext } from "../core/src/mcp-server"; | |
| 8 | −import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescriptor } from "../core/src/types"; | |
| 9 | −import type { Id } from "./_generated/dataModel"; | |
| 5 | +import { mcpHandler } from "./http/mcp_handler"; | |
| 6 | +import { | |
| 7 | + oauthAuthorizationServerHandler, | |
| 8 | + oauthAuthorizeHandler, | |
| 9 | + oauthJwksHandler, | |
| 10 | + oauthProtectedResourceHandler, | |
| 11 | + oauthRegisterHandler, | |
| 12 | + oauthTokenHandler, | |
| 13 | +} from "./http/oauth_handlers"; | |
| 10 | 14 | |
| 11 | 15 | const http = httpRouter(); |
| 12 | 16 | |
| 13 | −function getMcpAuthorizationServer(): string | null { | |
| 14 | − return process.env.MCP_AUTHORIZATION_SERVER | |
| 15 | − ?? process.env.MCP_AUTHORIZATION_SERVER_URL | |
| 16 | − ?? process.env.WORKOS_AUTHKIT_ISSUER | |
| 17 | − ?? process.env.WORKOS_AUTHKIT_DOMAIN | |
| 18 | − ?? null; | |
| 19 | −} | |
| 20 | − | |
| 21 | −function getMcpAuthConfig(): { | |
| 22 | − enabled: boolean; | |
| 23 | − authorizationServer: string | null; | |
| 24 | − jwks: ReturnType<typeof createRemoteJWKSet> | null; | |
| 25 | −} { | |
| 26 | − const authorizationServer = getMcpAuthorizationServer(); | |
| 27 | − if (!authorizationServer) { | |
| 28 | − return { | |
| 29 | − enabled: false, | |
| 30 | − authorizationServer: null, | |
| 31 | − jwks: null, | |
| 32 | − }; | |
| 33 | − } | |
| 34 | − | |
| 35 | − const jwks = createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer)); | |
| 36 | − return { | |
| 37 | − enabled: true, | |
| 38 | − authorizationServer, | |
| 39 | − jwks, | |
| 40 | − }; | |
| 41 | −} | |
| 42 | − | |
| 43 | −function parseBearerToken(request: Request): string | null { | |
| 44 | − const header = request.headers.get("authorization"); | |
| 45 | − if (!header || !header.startsWith("Bearer ")) return null; | |
| 46 | − const token = header.slice("Bearer ".length).trim(); | |
| 47 | − return token.length > 0 ? token : null; | |
| 48 | −} | |
| 49 | − | |
| 50 | −function resourceMetadataUrl(request: Request): string { | |
| 51 | − const url = new URL(request.url); | |
| 52 | − return `${url.origin}/.well-known/oauth-protected-resource`; | |
| 53 | −} | |
| 54 | − | |
| 55 | −function unauthorizedMcpResponse(request: Request, message: string): Response { | |
| 56 | − const challenge = [ | |
| 57 | − 'Bearer error="unauthorized"', | |
| 58 | − 'error_description="Authorization needed"', | |
| 59 | − `resource_metadata="${resourceMetadataUrl(request)}"`, | |
| 60 | − ].join(", "); | |
| 61 | − | |
| 62 | − return Response.json( | |
| 63 | − { error: message }, | |
| 64 | − { | |
| 65 | − status: 401, | |
| 66 | − headers: { | |
| 67 | − "WWW-Authenticate": challenge, | |
| 68 | − }, | |
| 69 | − }, | |
| 70 | − ); | |
| 71 | −} | |
| 72 | − | |
| 73 | −async function verifyMcpToken( | |
| 74 | − request: Request, | |
| 75 | − config: ReturnType<typeof getMcpAuthConfig>, | |
| 76 | −): Promise<{ subject: string } | null> { | |
| 77 | − if (!config.enabled || !config.authorizationServer || !config.jwks) { | |
| 78 | − return null; | |
| 79 | − } | |
| 80 | − | |
| 81 | − const token = parseBearerToken(request); | |
| 82 | − if (!token) { | |
| 83 | − return null; | |
| 84 | − } | |
| 85 | − | |
| 86 | − try { | |
| 87 | − const { payload } = await jwtVerify(token, config.jwks, { | |
| 88 | − issuer: config.authorizationServer, | |
| 89 | − }); | |
| 90 | − | |
| 91 | − if (typeof payload.sub !== "string" || payload.sub.length === 0) { | |
| 92 | − return null; | |
| 93 | − } | |
| 94 | − | |
| 95 | − return { subject: payload.sub }; | |
| 96 | − } catch { | |
| 97 | − return null; | |
| 98 | − } | |
| 99 | −} | |
| 100 | − | |
| 101 | −/** | |
| 102 | − * Validated boundary for external workspace ID strings. | |
| 103 | − * Convex IDs are opaque strings at runtime; this cast is the single | |
| 104 | − * validated entry point so downstream code never needs ad-hoc casts. | |
| 105 | − */ | |
| 106 | −function parseWorkspaceId(raw: string): Id<"workspaces"> { | |
| 107 | − return raw as Id<"workspaces">; | |
| 108 | −} | |
| 109 | − | |
| 110 | −function parseMcpContext(url: URL): { | |
| 111 | − workspaceId: Id<"workspaces">; | |
| 112 | − clientId?: string; | |
| 113 | − sessionId?: string; | |
| 114 | −} | undefined { | |
| 115 | − const raw = url.searchParams.get("workspaceId"); | |
| 116 | − if (!raw) return undefined; | |
| 117 | − const workspaceId = parseWorkspaceId(raw); | |
| 118 | − const clientId = url.searchParams.get("clientId") ?? undefined; | |
| 119 | − const sessionId = url.searchParams.get("sessionId") ?? undefined; | |
| 120 | − return { workspaceId, clientId, sessionId }; | |
| 121 | −} | |
| 122 | − | |
| 123 | −const mcpHandler = httpAction(async (ctx, request) => { | |
| 124 | − const url = new URL(request.url); | |
| 125 | − const mcpAuthConfig = getMcpAuthConfig(); | |
| 126 | − const auth = await verifyMcpToken(request, mcpAuthConfig); | |
| 127 | − const requestedContext = parseMcpContext(url); | |
| 128 | − | |
| 129 | − if (mcpAuthConfig.enabled && !requestedContext) { | |
| 130 | − return Response.json( | |
| 131 | − { error: "workspaceId query parameter is required when MCP OAuth is enabled" }, | |
| 132 | − { status: 400 }, | |
| 133 | − ); | |
| 134 | − } | |
| 135 | − | |
| 136 | − let context: McpWorkspaceContext | undefined; | |
| 137 | − if (requestedContext) { | |
| 138 | − try { | |
| 139 | − if (mcpAuthConfig.enabled && auth?.subject) { | |
| 140 | − const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForWorkosSubject, { | |
| 141 | − workspaceId: requestedContext.workspaceId, | |
| 142 | − subject: auth.subject, | |
| 143 | − }); | |
| 144 | − | |
| 145 | − context = { | |
| 146 | − workspaceId: requestedContext.workspaceId, | |
| 147 | − actorId: access.actorId, | |
| 148 | − clientId: requestedContext.clientId, | |
| 149 | − }; | |
| 150 | − } else { | |
| 151 | − if (mcpAuthConfig.enabled && !requestedContext.sessionId) { | |
| 152 | − return unauthorizedMcpResponse(request, "No valid bearer token provided."); | |
| 153 | − } | |
| 154 | − | |
| 155 | − const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, { | |
| 156 | − workspaceId: requestedContext.workspaceId, | |
| 157 | − sessionId: requestedContext.sessionId, | |
| 158 | − }); | |
| 159 | − | |
| 160 | − if (mcpAuthConfig.enabled && access.provider !== "anonymous") { | |
| 161 | − return unauthorizedMcpResponse( | |
| 162 | − request, | |
| 163 | − "Bearer token required for non-anonymous sessions.", | |
| 164 | − ); | |
| 165 | − } | |
| 166 | − | |
| 167 | − context = { | |
| 168 | − workspaceId: requestedContext.workspaceId, | |
| 169 | − actorId: access.actorId, | |
| 170 | − clientId: requestedContext.clientId, | |
| 171 | − sessionId: requestedContext.sessionId, | |
| 172 | − }; | |
| 173 | − } | |
| 174 | − } catch (error) { | |
| 175 | − return Response.json( | |
| 176 | − { error: error instanceof Error ? error.message : "Workspace authorization failed" }, | |
| 177 | − { status: 403 }, | |
| 178 | − ); | |
| 179 | − } | |
| 180 | − } | |
| 181 | − | |
| 182 | − const service = { | |
| 183 | − createTask: async (input: { | |
| 184 | − code: string; | |
| 185 | − timeoutMs?: number; | |
| 186 | − runtimeId?: string; | |
| 187 | − metadata?: Record<string, unknown>; | |
| 188 | − workspaceId: Id<"workspaces">; | |
| 189 | − actorId: string; | |
| 190 | − clientId?: string; | |
| 191 | − }) => { | |
| 192 | − return (await ctx.runMutation(internal.executor.createTaskInternal, { | |
| 193 | − ...input, | |
| 194 | − })) as { task: TaskRecord }; | |
| 195 | − }, | |
| 196 | − getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => { | |
| 197 | − if (workspaceId) { | |
| 198 | − return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId })) as TaskRecord | null; | |
| 199 | − } | |
| 200 | − return null; | |
| 201 | − }, | |
| 202 | − subscribe: () => { | |
| 203 | − return () => {}; | |
| 204 | − }, | |
| 205 | − bootstrapAnonymousContext: async (sessionId?: string) => { | |
| 206 | − return (await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId })) as AnonymousContext; | |
| 207 | − }, | |
| 208 | − listTools: async (toolContext?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => { | |
| 209 | − if (!toolContext) { | |
| 210 | − return []; | |
| 211 | − } | |
| 212 | − | |
| 213 | − return (await ctx.runAction(internal.executorNode.listToolsInternal, { ...toolContext })) as ToolDescriptor[]; | |
| 214 | − }, | |
| 215 | − listToolsForTypecheck: async (toolContext: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => { | |
| 216 | − const result = await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, { ...toolContext }) as { | |
| 217 | − tools: ToolDescriptor[]; | |
| 218 | − dtsUrls?: Record<string, string>; | |
| 219 | − }; | |
| 220 | − | |
| 221 | − return { | |
| 222 | − tools: result.tools, | |
| 223 | − dtsUrls: result.dtsUrls ?? {}, | |
| 224 | − }; | |
| 225 | − }, | |
| 226 | − typecheckRunCode: async (input: { | |
| 227 | − code: string; | |
| 228 | − workspaceId: Id<"workspaces">; | |
| 229 | − actorId?: string; | |
| 230 | − clientId?: string; | |
| 231 | − }) => { | |
| 232 | − const result = await ctx.runAction(internal.mcpNode.typecheckRunCodeInternal, { | |
| 233 | − code: input.code, | |
| 234 | − workspaceId: input.workspaceId, | |
| 235 | − actorId: input.actorId, | |
| 236 | − clientId: input.clientId, | |
| 237 | − }); | |
| 238 | − return result; | |
| 239 | − }, | |
| 240 | − listPendingApprovals: async (workspaceId: Id<"workspaces">) => { | |
| 241 | − return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[]; | |
| 242 | − }, | |
| 243 | − resolveApproval: async (input: { | |
| 244 | − workspaceId: Id<"workspaces">; | |
| 245 | − approvalId: string; | |
| 246 | − decision: "approved" | "denied"; | |
| 247 | − reviewerId?: string; | |
| 248 | − reason?: string; | |
| 249 | − }) => { | |
| 250 | − return await ctx.runMutation(internal.executor.resolveApprovalInternal, { | |
| 251 | − ...input, | |
| 252 | − }); | |
| 253 | − }, | |
| 254 | − }; | |
| 255 | − | |
| 256 | − return await handleMcpRequest(service, request, context); | |
| 257 | −}); | |
| 258 | − | |
| 259 | −const oauthProtectedResourceHandler = httpAction(async (_ctx, request) => { | |
| 260 | − const mcpAuthConfig = getMcpAuthConfig(); | |
| 261 | − if (!mcpAuthConfig.enabled || !mcpAuthConfig.authorizationServer) { | |
| 262 | − return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 }); | |
| 263 | − } | |
| 264 | − | |
| 265 | − const url = new URL(request.url); | |
| 266 | − return Response.json({ | |
| 267 | − resource: `${url.origin}/mcp`, | |
| 268 | − authorization_servers: [mcpAuthConfig.authorizationServer], | |
| 269 | − bearer_methods_supported: ["header"], | |
| 270 | − }); | |
| 271 | −}); | |
| 272 | − | |
| 273 | −const oauthAuthorizationServerProxyHandler = httpAction(async (_ctx, request) => { | |
| 274 | − const mcpAuthConfig = getMcpAuthConfig(); | |
| 275 | − if (!mcpAuthConfig.enabled || !mcpAuthConfig.authorizationServer) { | |
| 276 | − return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 }); | |
| 277 | − } | |
| 278 | − | |
| 279 | − const upstream = new URL("/.well-known/oauth-authorization-server", mcpAuthConfig.authorizationServer); | |
| 280 | − const response = await fetch(upstream.toString(), { | |
| 281 | − headers: { accept: "application/json" }, | |
| 282 | − }); | |
| 283 | − | |
| 284 | − const text = await response.text(); | |
| 285 | − return new Response(text, { | |
| 286 | − status: response.status, | |
| 287 | − headers: { | |
| 288 | − "content-type": response.headers.get("content-type") ?? "application/json", | |
| 289 | − }, | |
| 290 | − }); | |
| 291 | −}); | |
| 292 | − | |
| 293 | 17 | authKit.registerRoutes(http); |
| 294 | 18 | registerStripeRoutes(http, components.stripe, { |
| 295 | 19 | webhookPath: "/stripe/webhook", |
| @@ -298,7 +22,12 @@ registerStripeRoutes(http, components.stripe, { · 2 unmodified lines | ||
| 298 | 22 | http.route({ path: "/mcp", method: "POST", handler: mcpHandler }); |
| 299 | 23 | http.route({ path: "/mcp", method: "GET", handler: mcpHandler }); |
| 300 | 24 | http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler }); |
| 25 | + | |
| 301 | 26 | http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler }); |
| 302 | −http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerProxyHandler }); | |
| 27 | +http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerHandler }); | |
| 28 | +http.route({ path: "/oauth2/jwks", method: "GET", handler: oauthJwksHandler }); | |
| 29 | +http.route({ path: "/register", method: "POST", handler: oauthRegisterHandler }); | |
| 30 | +http.route({ path: "/authorize", method: "GET", handler: oauthAuthorizeHandler }); | |
| 31 | +http.route({ path: "/token", method: "POST", handler: oauthTokenHandler }); | |
| 303 | 32 | |
| 304 | 33 | export default http; |
| @@ -0,0 +1,136 @@ | ||
| 1 | +import { internal } from "../_generated/api"; | |
| 2 | +import type { ActionCtx } from "../_generated/server"; | |
| 3 | +import type { | |
| 4 | + AnonOAuthClientRegistration, | |
| 5 | + OAuthStorage, | |
| 6 | + StoredSigningKey, | |
| 7 | +} from "../../core/src/anonymous-oauth"; | |
| 8 | + | |
| 9 | +export class ActionOAuthStorage implements OAuthStorage { | |
| 10 | + constructor(private readonly ctx: ActionCtx) {} | |
| 11 | + | |
| 12 | + async getActiveSigningKey(): Promise<StoredSigningKey | null> { | |
| 13 | + const result = await this.ctx.runQuery( | |
| 14 | + internal.database.getActiveAnonymousOauthSigningKey, | |
| 15 | + {}, | |
| 16 | + ); | |
| 17 | + | |
| 18 | + if (!result) return null; | |
| 19 | + | |
| 20 | + return { | |
| 21 | + keyId: result.keyId, | |
| 22 | + algorithm: result.algorithm, | |
| 23 | + privateKeyJwk: result.privateKeyJwk, | |
| 24 | + publicKeyJwk: result.publicKeyJwk, | |
| 25 | + }; | |
| 26 | + } | |
| 27 | + | |
| 28 | + async storeSigningKey(key: StoredSigningKey): Promise<void> { | |
| 29 | + await this.ctx.runMutation( | |
| 30 | + internal.database.storeAnonymousOauthSigningKey, | |
| 31 | + { | |
| 32 | + keyId: key.keyId, | |
| 33 | + algorithm: key.algorithm, | |
| 34 | + privateKeyJwk: key.privateKeyJwk, | |
| 35 | + publicKeyJwk: key.publicKeyJwk, | |
| 36 | + }, | |
| 37 | + ); | |
| 38 | + } | |
| 39 | + | |
| 40 | + async registerClient( | |
| 41 | + registration: AnonOAuthClientRegistration, | |
| 42 | + ): Promise<AnonOAuthClientRegistration> { | |
| 43 | + const result = await this.ctx.runMutation( | |
| 44 | + internal.database.registerAnonymousOauthClient, | |
| 45 | + { | |
| 46 | + clientId: registration.client_id, | |
| 47 | + clientName: registration.client_name, | |
| 48 | + redirectUris: registration.redirect_uris, | |
| 49 | + }, | |
| 50 | + ); | |
| 51 | + | |
| 52 | + return { | |
| 53 | + client_id: result.client_id, | |
| 54 | + client_name: result.client_name ?? undefined, | |
| 55 | + redirect_uris: result.redirect_uris, | |
| 56 | + created_at: result.created_at, | |
| 57 | + }; | |
| 58 | + } | |
| 59 | + | |
| 60 | + async getClient(clientId: string): Promise<AnonOAuthClientRegistration | null> { | |
| 61 | + const result = await this.ctx.runQuery( | |
| 62 | + internal.database.getAnonymousOauthClient, | |
| 63 | + { clientId }, | |
| 64 | + ); | |
| 65 | + | |
| 66 | + if (!result) return null; | |
| 67 | + | |
| 68 | + return { | |
| 69 | + client_id: result.client_id, | |
| 70 | + client_name: result.client_name ?? undefined, | |
| 71 | + redirect_uris: result.redirect_uris, | |
| 72 | + created_at: result.created_at, | |
| 73 | + }; | |
| 74 | + } | |
| 75 | + | |
| 76 | + async storeAuthorizationCode(code: { | |
| 77 | + code: string; | |
| 78 | + clientId: string; | |
| 79 | + redirectUri: string; | |
| 80 | + codeChallenge: string; | |
| 81 | + codeChallengeMethod: string; | |
| 82 | + actorId: string; | |
| 83 | + tokenClaims?: Record<string, unknown>; | |
| 84 | + expiresAt: number; | |
| 85 | + createdAt: number; | |
| 86 | + }): Promise<void> { | |
| 87 | + await this.ctx.runMutation(internal.database.storeAnonymousOauthAuthorizationCode, { | |
| 88 | + ...code, | |
| 89 | + }); | |
| 90 | + } | |
| 91 | + | |
| 92 | + async consumeAuthorizationCode(code: string): Promise<{ | |
| 93 | + code: string; | |
| 94 | + clientId: string; | |
| 95 | + redirectUri: string; | |
| 96 | + codeChallenge: string; | |
| 97 | + codeChallengeMethod: string; | |
| 98 | + actorId: string; | |
| 99 | + tokenClaims?: Record<string, unknown>; | |
| 100 | + expiresAt: number; | |
| 101 | + createdAt: number; | |
| 102 | + } | null> { | |
| 103 | + const result = await this.ctx.runMutation(internal.database.consumeAnonymousOauthAuthorizationCode, { | |
| 104 | + code, | |
| 105 | + }); | |
| 106 | + | |
| 107 | + if (!result) { | |
| 108 | + return null; | |
| 109 | + } | |
| 110 | + | |
| 111 | + return { | |
| 112 | + code: result.code, | |
| 113 | + clientId: result.clientId, | |
| 114 | + redirectUri: result.redirectUri, | |
| 115 | + codeChallenge: result.codeChallenge, | |
| 116 | + codeChallengeMethod: result.codeChallengeMethod, | |
| 117 | + actorId: result.actorId, | |
| 118 | + tokenClaims: result.tokenClaims ?? undefined, | |
| 119 | + expiresAt: result.expiresAt, | |
| 120 | + createdAt: result.createdAt, | |
| 121 | + }; | |
| 122 | + } | |
| 123 | + | |
| 124 | + async purgeExpiredAuthorizationCodes(now: number): Promise<number> { | |
| 125 | + const result = await this.ctx.runMutation(internal.database.purgeExpiredAnonymousOauthAuthorizationCodes, { | |
| 126 | + now, | |
| 127 | + }); | |
| 128 | + return result.purged; | |
| 129 | + } | |
| 130 | + | |
| 131 | + async countAuthorizationCodes(): Promise<number> { | |
| 132 | + const result = await this.ctx.runQuery(internal.database.countAnonymousOauthAuthorizationCodes, { | |
| 133 | + }); | |
| 134 | + return result.count; | |
| 135 | + } | |
| 136 | +} | |
| @@ -0,0 +1,239 @@ | ||
| 1 | +import { createRemoteJWKSet, jwtVerify } from "jose"; | |
| 2 | +import type { Id } from "../_generated/dataModel"; | |
| 3 | +import type { ActionCtx } from "../_generated/server"; | |
| 4 | +import { | |
| 5 | + AnonymousOAuthServer, | |
| 6 | +} from "../../core/src/anonymous-oauth"; | |
| 7 | +import { ActionOAuthStorage } from "./action_oauth_storage"; | |
| 8 | + | |
| 9 | +export const ANON_WORKSPACE_CLAIM = "workspace_id"; | |
| 10 | +export const ANON_SESSION_CLAIM = "session_id"; | |
| 11 | + | |
| 12 | +export type McpAuthConfig = { | |
| 13 | + enabled: boolean; | |
| 14 | + anonymousEnabled: boolean; | |
| 15 | + authorizationServer: string | null; | |
| 16 | + jwks: ReturnType<typeof createRemoteJWKSet> | null; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +export type VerifiedMcpToken = | |
| 20 | + | { provider: "workos"; subject: string } | |
| 21 | + | { provider: "anonymous"; subject: string; workspaceId?: Id<"workspaces">; sessionId?: string }; | |
| 22 | + | |
| 23 | +export type ParsedMcpContext = { | |
| 24 | + workspaceId?: Id<"workspaces">; | |
| 25 | + clientId?: string; | |
| 26 | + sessionId?: string; | |
| 27 | +}; | |
| 28 | + | |
| 29 | +export function parseWorkspaceId(raw: string): Id<"workspaces"> { | |
| 30 | + return raw as Id<"workspaces">; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export function getMcpAuthorizationServer(): string | null { | |
| 34 | + return process.env.MCP_AUTHORIZATION_SERVER | |
| 35 | + ?? process.env.MCP_AUTHORIZATION_SERVER_URL | |
| 36 | + ?? process.env.WORKOS_AUTHKIT_ISSUER | |
| 37 | + ?? process.env.WORKOS_AUTHKIT_DOMAIN | |
| 38 | + ?? null; | |
| 39 | +} | |
| 40 | + | |
| 41 | +export function getMcpAuthConfig(): McpAuthConfig { | |
| 42 | + const authorizationServer = getMcpAuthorizationServer(); | |
| 43 | + const anonymousEnabled = process.env.MCP_ENABLE_ANONYMOUS_OAUTH === "1" || Boolean(authorizationServer); | |
| 44 | + if (!authorizationServer && !anonymousEnabled) { | |
| 45 | + return { | |
| 46 | + enabled: false, | |
| 47 | + anonymousEnabled: false, | |
| 48 | + authorizationServer: null, | |
| 49 | + jwks: null, | |
| 50 | + }; | |
| 51 | + } | |
| 52 | + | |
| 53 | + const jwks = authorizationServer | |
| 54 | + ? createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer)) | |
| 55 | + : null; | |
| 56 | + | |
| 57 | + return { | |
| 58 | + enabled: true, | |
| 59 | + anonymousEnabled, | |
| 60 | + authorizationServer, | |
| 61 | + jwks, | |
| 62 | + }; | |
| 63 | +} | |
| 64 | + | |
| 65 | +export function isAnonymousSessionId(sessionId?: string): boolean { | |
| 66 | + if (!sessionId) return false; | |
| 67 | + return sessionId.startsWith("anon_session_") || sessionId.startsWith("mcp_"); | |
| 68 | +} | |
| 69 | + | |
| 70 | +export function selectMcpAuthProvider( | |
| 71 | + request: Request, | |
| 72 | + config: McpAuthConfig, | |
| 73 | +): "workos" | "anonymous" | null { | |
| 74 | + if (!config.enabled) { | |
| 75 | + return null; | |
| 76 | + } | |
| 77 | + | |
| 78 | + const url = new URL(request.url); | |
| 79 | + const sessionId = url.searchParams.get("sessionId") ?? undefined; | |
| 80 | + if (config.anonymousEnabled && isAnonymousSessionId(sessionId)) { | |
| 81 | + return "anonymous"; | |
| 82 | + } | |
| 83 | + | |
| 84 | + if (config.authorizationServer) { | |
| 85 | + return "workos"; | |
| 86 | + } | |
| 87 | + | |
| 88 | + if (config.anonymousEnabled) { | |
| 89 | + return "anonymous"; | |
| 90 | + } | |
| 91 | + | |
| 92 | + return null; | |
| 93 | +} | |
| 94 | + | |
| 95 | +export async function getAnonymousOAuthServer( | |
| 96 | + ctx: ActionCtx, | |
| 97 | + request: Request, | |
| 98 | +): Promise<AnonymousOAuthServer> { | |
| 99 | + const issuer = new URL(request.url).origin; | |
| 100 | + const server = new AnonymousOAuthServer({ | |
| 101 | + issuer, | |
| 102 | + storage: new ActionOAuthStorage(ctx), | |
| 103 | + }); | |
| 104 | + await server.init(); | |
| 105 | + return server; | |
| 106 | +} | |
| 107 | + | |
| 108 | +function parseBearerToken(request: Request): string | null { | |
| 109 | + const header = request.headers.get("authorization"); | |
| 110 | + if (!header || !header.startsWith("Bearer ")) return null; | |
| 111 | + const token = header.slice("Bearer ".length).trim(); | |
| 112 | + return token.length > 0 ? token : null; | |
| 113 | +} | |
| 114 | + | |
| 115 | +function resourceMetadataUrl(request: Request): string { | |
| 116 | + const url = new URL(request.url); | |
| 117 | + const metadata = new URL("/.well-known/oauth-protected-resource", url.origin); | |
| 118 | + metadata.search = url.search; | |
| 119 | + return metadata.toString(); | |
| 120 | +} | |
| 121 | + | |
| 122 | +export function unauthorizedMcpResponse(request: Request, message: string): Response { | |
| 123 | + const challenge = [ | |
| 124 | + 'Bearer error="unauthorized"', | |
| 125 | + 'error_description="Authorization needed"', | |
| 126 | + `resource_metadata="${resourceMetadataUrl(request)}"`, | |
| 127 | + ].join(", "); | |
| 128 | + | |
| 129 | + return Response.json( | |
| 130 | + { error: message }, | |
| 131 | + { | |
| 132 | + status: 401, | |
| 133 | + headers: { | |
| 134 | + "WWW-Authenticate": challenge, | |
| 135 | + }, | |
| 136 | + }, | |
| 137 | + ); | |
| 138 | +} | |
| 139 | + | |
| 140 | +export async function verifyMcpToken( | |
| 141 | + ctx: ActionCtx, | |
| 142 | + request: Request, | |
| 143 | + config: McpAuthConfig, | |
| 144 | +): Promise<VerifiedMcpToken | null> { | |
| 145 | + if (!config.enabled) { | |
| 146 | + return null; | |
| 147 | + } | |
| 148 | + | |
| 149 | + const token = parseBearerToken(request); | |
| 150 | + if (!token) { | |
| 151 | + return null; | |
| 152 | + } | |
| 153 | + | |
| 154 | + if (config.authorizationServer && config.jwks) { | |
| 155 | + try { | |
| 156 | + const { payload } = await jwtVerify(token, config.jwks, { | |
| 157 | + issuer: config.authorizationServer, | |
| 158 | + }); | |
| 159 | + | |
| 160 | + if (typeof payload.sub === "string" && payload.sub.length > 0) { | |
| 161 | + const providerClaim = typeof payload.provider === "string" ? payload.provider : undefined; | |
| 162 | + if (providerClaim !== "anonymous") { | |
| 163 | + return { | |
| 164 | + provider: "workos", | |
| 165 | + subject: payload.sub, | |
| 166 | + }; | |
| 167 | + } | |
| 168 | + } | |
| 169 | + } catch { | |
| 170 | + // Fall through to anonymous-token verification. | |
| 171 | + } | |
| 172 | + } | |
| 173 | + | |
| 174 | + try { | |
| 175 | + const anonymousOauthServer = await getAnonymousOAuthServer(ctx, request); | |
| 176 | + const verified = await anonymousOauthServer.verifyToken(token); | |
| 177 | + if (!verified || verified.provider !== "anonymous") { | |
| 178 | + return null; | |
| 179 | + } | |
| 180 | + | |
| 181 | + const workspaceClaim = verified.claims[ANON_WORKSPACE_CLAIM]; | |
| 182 | + const sessionClaim = verified.claims[ANON_SESSION_CLAIM]; | |
| 183 | + | |
| 184 | + return { | |
| 185 | + provider: "anonymous", | |
| 186 | + subject: verified.sub, | |
| 187 | + workspaceId: typeof workspaceClaim === "string" ? parseWorkspaceId(workspaceClaim) : undefined, | |
| 188 | + sessionId: typeof sessionClaim === "string" ? sessionClaim : undefined, | |
| 189 | + }; | |
| 190 | + } catch { | |
| 191 | + return null; | |
| 192 | + } | |
| 193 | +} | |
| 194 | + | |
| 195 | +export function parseMcpContext(url: URL): ParsedMcpContext | undefined { | |
| 196 | + const raw = url.searchParams.get("workspaceId"); | |
| 197 | + const workspaceId = raw ? parseWorkspaceId(raw) : undefined; | |
| 198 | + const clientId = url.searchParams.get("clientId") ?? undefined; | |
| 199 | + const sessionId = url.searchParams.get("sessionId") ?? undefined; | |
| 200 | + if (!workspaceId && !clientId && !sessionId) { | |
| 201 | + return undefined; | |
| 202 | + } | |
| 203 | + return { workspaceId, clientId, sessionId }; | |
| 204 | +} | |
| 205 | + | |
| 206 | +export function parseAnonymousAuthorizeContext(params: URLSearchParams): { | |
| 207 | + workspaceId: Id<"workspaces">; | |
| 208 | + sessionId: string; | |
| 209 | +} | null { | |
| 210 | + const directWorkspaceId = params.get("workspaceId"); | |
| 211 | + const directSessionId = params.get("sessionId"); | |
| 212 | + if (directWorkspaceId && directSessionId && isAnonymousSessionId(directSessionId)) { | |
| 213 | + return { | |
| 214 | + workspaceId: parseWorkspaceId(directWorkspaceId), | |
| 215 | + sessionId: directSessionId, | |
| 216 | + }; | |
| 217 | + } | |
| 218 | + | |
| 219 | + const resource = params.get("resource"); | |
| 220 | + if (!resource) { | |
| 221 | + return null; | |
| 222 | + } | |
| 223 | + | |
| 224 | + try { | |
| 225 | + const resourceUrl = new URL(resource); | |
| 226 | + const workspaceId = resourceUrl.searchParams.get("workspaceId"); | |
| 227 | + const sessionId = resourceUrl.searchParams.get("sessionId"); | |
| 228 | + if (!workspaceId || !sessionId || !isAnonymousSessionId(sessionId)) { | |
| 229 | + return null; | |
| 230 | + } | |
| 231 | + | |
| 232 | + return { | |
| 233 | + workspaceId: parseWorkspaceId(workspaceId), | |
| 234 | + sessionId, | |
| 235 | + }; | |
| 236 | + } catch { | |
| 237 | + return null; | |
| 238 | + } | |
| 239 | +} | |
| @@ -0,0 +1,132 @@ | ||
| 1 | +import { httpAction } from "../_generated/server"; | |
| 2 | +import { internal } from "../_generated/api"; | |
| 3 | +import { handleMcpRequest, type McpWorkspaceContext } from "../../core/src/mcp-server"; | |
| 4 | +import { | |
| 5 | + getMcpAuthConfig, | |
| 6 | + parseMcpContext, | |
| 7 | + unauthorizedMcpResponse, | |
| 8 | + verifyMcpToken, | |
| 9 | +} from "./mcp_auth"; | |
| 10 | +import { createMcpExecutorService } from "./mcp_service"; | |
| 11 | + | |
| 12 | +export const mcpHandler = httpAction(async (ctx, request) => { | |
| 13 | + const url = new URL(request.url); | |
| 14 | + const mcpAuthConfig = getMcpAuthConfig(); | |
| 15 | + const auth = await verifyMcpToken(ctx, request, mcpAuthConfig); | |
| 16 | + const requestedContext = parseMcpContext(url); | |
| 17 | + | |
| 18 | + if (mcpAuthConfig.enabled && !auth) { | |
| 19 | + return unauthorizedMcpResponse(request, "No valid bearer token provided."); | |
| 20 | + } | |
| 21 | + | |
| 22 | + if (mcpAuthConfig.enabled && auth?.provider === "workos" && !requestedContext?.workspaceId) { | |
| 23 | + return Response.json( | |
| 24 | + { error: "workspaceId query parameter is required when MCP OAuth is enabled" }, | |
| 25 | + { status: 400 }, | |
| 26 | + ); | |
| 27 | + } | |
| 28 | + | |
| 29 | + let context: McpWorkspaceContext | undefined; | |
| 30 | + const hasRequestedWorkspace = Boolean(requestedContext?.workspaceId); | |
| 31 | + const hasAnonymousTokenContext = auth?.provider === "anonymous" && Boolean(auth.workspaceId || auth.sessionId); | |
| 32 | + | |
| 33 | + if (hasRequestedWorkspace || hasAnonymousTokenContext) { | |
| 34 | + try { | |
| 35 | + if (auth?.provider === "workos") { | |
| 36 | + const workspaceId = requestedContext?.workspaceId; | |
| 37 | + if (!workspaceId) { | |
| 38 | + return Response.json( | |
| 39 | + { error: "workspaceId query parameter is required when MCP OAuth is enabled" }, | |
| 40 | + { status: 400 }, | |
| 41 | + ); | |
| 42 | + } | |
| 43 | + | |
| 44 | + const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForWorkosSubject, { | |
| 45 | + workspaceId, | |
| 46 | + subject: auth.subject, | |
| 47 | + }); | |
| 48 | + | |
| 49 | + context = { | |
| 50 | + workspaceId, | |
| 51 | + actorId: access.actorId, | |
| 52 | + clientId: requestedContext?.clientId, | |
| 53 | + }; | |
| 54 | + } else if (auth?.provider === "anonymous") { | |
| 55 | + const workspaceId = requestedContext?.workspaceId ?? auth.workspaceId; | |
| 56 | + const sessionId = requestedContext?.sessionId ?? auth.sessionId; | |
| 57 | + | |
| 58 | + if (!workspaceId || !sessionId) { | |
| 59 | + return unauthorizedMcpResponse( | |
| 60 | + request, | |
| 61 | + "Anonymous OAuth token must include workspace and session context.", | |
| 62 | + ); | |
| 63 | + } | |
| 64 | + | |
| 65 | + if (requestedContext?.workspaceId && auth.workspaceId && requestedContext.workspaceId !== auth.workspaceId) { | |
| 66 | + return unauthorizedMcpResponse(request, "Anonymous token workspace does not match requested workspace."); | |
| 67 | + } | |
| 68 | + if (requestedContext?.sessionId && auth.sessionId && requestedContext.sessionId !== auth.sessionId) { | |
| 69 | + return unauthorizedMcpResponse(request, "Anonymous token session does not match requested session."); | |
| 70 | + } | |
| 71 | + | |
| 72 | + const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, { | |
| 73 | + workspaceId, | |
| 74 | + sessionId, | |
| 75 | + }); | |
| 76 | + | |
| 77 | + if (access.provider !== "anonymous") { | |
| 78 | + return unauthorizedMcpResponse(request, "Anonymous OAuth token requires an anonymous session."); | |
| 79 | + } | |
| 80 | + if (access.actorId !== auth.subject) { | |
| 81 | + return unauthorizedMcpResponse(request, "Anonymous token subject does not match session actor."); | |
| 82 | + } | |
| 83 | + | |
| 84 | + context = { | |
| 85 | + workspaceId, | |
| 86 | + actorId: access.actorId, | |
| 87 | + clientId: requestedContext?.clientId, | |
| 88 | + sessionId, | |
| 89 | + }; | |
| 90 | + } else { | |
| 91 | + const workspaceId = requestedContext?.workspaceId; | |
| 92 | + if (!workspaceId) { | |
| 93 | + return Response.json( | |
| 94 | + { error: "workspaceId query parameter is required when MCP OAuth is enabled" }, | |
| 95 | + { status: 400 }, | |
| 96 | + ); | |
| 97 | + } | |
| 98 | + | |
| 99 | + if (mcpAuthConfig.enabled && !requestedContext?.sessionId) { | |
| 100 | + return unauthorizedMcpResponse(request, "No valid bearer token provided."); | |
| 101 | + } | |
| 102 | + | |
| 103 | + const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, { | |
| 104 | + workspaceId, | |
| 105 | + sessionId: requestedContext?.sessionId, | |
| 106 | + }); | |
| 107 | + | |
| 108 | + if (mcpAuthConfig.enabled && access.provider !== "anonymous") { | |
| 109 | + return unauthorizedMcpResponse( | |
| 110 | + request, | |
| 111 | + "Bearer token required for non-anonymous sessions.", | |
| 112 | + ); | |
| 113 | + } | |
| 114 | + | |
| 115 | + context = { | |
| 116 | + workspaceId, | |
| 117 | + actorId: access.actorId, | |
| 118 | + clientId: requestedContext?.clientId, | |
| 119 | + sessionId: requestedContext?.sessionId, | |
| 120 | + }; | |
| 121 | + } | |
| 122 | + } catch (error) { | |
| 123 | + return Response.json( | |
| 124 | + { error: error instanceof Error ? error.message : "Workspace authorization failed" }, | |
| 125 | + { status: 403 }, | |
| 126 | + ); | |
| 127 | + } | |
| 128 | + } | |
| 129 | + | |
| 130 | + const service = createMcpExecutorService(ctx); | |
| 131 | + return await handleMcpRequest(service, request, context); | |
| 132 | +}); | |
| @@ -0,0 +1,85 @@ | ||
| 1 | +import type { ActionCtx } from "../_generated/server"; | |
| 2 | +import { internal } from "../_generated/api"; | |
| 3 | +import type { Id } from "../_generated/dataModel"; | |
| 4 | +import type { | |
| 5 | + AnonymousContext, | |
| 6 | + PendingApprovalRecord, | |
| 7 | + TaskRecord, | |
| 8 | + ToolDescriptor, | |
| 9 | +} from "../../core/src/types"; | |
| 10 | + | |
| 11 | +export function createMcpExecutorService(ctx: ActionCtx) { | |
| 12 | + return { | |
| 13 | + createTask: async (input: { | |
| 14 | + code: string; | |
| 15 | + timeoutMs?: number; | |
| 16 | + runtimeId?: string; | |
| 17 | + metadata?: Record<string, unknown>; | |
| 18 | + workspaceId: Id<"workspaces">; | |
| 19 | + actorId: string; | |
| 20 | + clientId?: string; | |
| 21 | + }) => { | |
| 22 | + return (await ctx.runMutation(internal.executor.createTaskInternal, { | |
| 23 | + ...input, | |
| 24 | + })) as { task: TaskRecord }; | |
| 25 | + }, | |
| 26 | + getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => { | |
| 27 | + if (workspaceId) { | |
| 28 | + return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId })) as TaskRecord | null; | |
| 29 | + } | |
| 30 | + return null; | |
| 31 | + }, | |
| 32 | + subscribe: () => { | |
| 33 | + return () => {}; | |
| 34 | + }, | |
| 35 | + bootstrapAnonymousContext: async (sessionId?: string) => { | |
| 36 | + return (await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId })) as AnonymousContext; | |
| 37 | + }, | |
| 38 | + listTools: async (toolContext?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => { | |
| 39 | + if (!toolContext) { | |
| 40 | + return []; | |
| 41 | + } | |
| 42 | + | |
| 43 | + return (await ctx.runAction(internal.executorNode.listToolsInternal, { ...toolContext })) as ToolDescriptor[]; | |
| 44 | + }, | |
| 45 | + listToolsForTypecheck: async (toolContext: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => { | |
| 46 | + const result = await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, { ...toolContext }) as { | |
| 47 | + tools: ToolDescriptor[]; | |
| 48 | + dtsUrls?: Record<string, string>; | |
| 49 | + }; | |
| 50 | + | |
| 51 | + return { | |
| 52 | + tools: result.tools, | |
| 53 | + dtsUrls: result.dtsUrls ?? {}, | |
| 54 | + }; | |
| 55 | + }, | |
| 56 | + typecheckRunCode: async (input: { | |
| 57 | + code: string; | |
| 58 | + workspaceId: Id<"workspaces">; | |
| 59 | + actorId?: string; | |
| 60 | + clientId?: string; | |
| 61 | + }) => { | |
| 62 | + const result = await ctx.runAction(internal.mcpNode.typecheckRunCodeInternal, { | |
| 63 | + code: input.code, | |
| 64 | + workspaceId: input.workspaceId, | |
| 65 | + actorId: input.actorId, | |
| 66 | + clientId: input.clientId, | |
| 67 | + }); | |
| 68 | + return result; | |
| 69 | + }, | |
| 70 | + listPendingApprovals: async (workspaceId: Id<"workspaces">) => { | |
| 71 | + return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[]; | |
| 72 | + }, | |
| 73 | + resolveApproval: async (input: { | |
| 74 | + workspaceId: Id<"workspaces">; | |
| 75 | + approvalId: string; | |
| 76 | + decision: "approved" | "denied"; | |
| 77 | + reviewerId?: string; | |
| 78 | + reason?: string; | |
| 79 | + }) => { | |
| 80 | + return await ctx.runMutation(internal.executor.resolveApprovalInternal, { | |
| 81 | + ...input, | |
| 82 | + }); | |
| 83 | + }, | |
| 84 | + }; | |
| 85 | +} | |
| @@ -0,0 +1,213 @@ | ||
| 1 | +import { httpAction } from "../_generated/server"; | |
| 2 | +import { internal } from "../_generated/api"; | |
| 3 | +import { OAuthBadRequest } from "../../core/src/anonymous-oauth"; | |
| 4 | +import { | |
| 5 | + ANON_SESSION_CLAIM, | |
| 6 | + ANON_WORKSPACE_CLAIM, | |
| 7 | + getAnonymousOAuthServer, | |
| 8 | + getMcpAuthConfig, | |
| 9 | + isAnonymousSessionId, | |
| 10 | + parseAnonymousAuthorizeContext, | |
| 11 | + selectMcpAuthProvider, | |
| 12 | +} from "./mcp_auth"; | |
| 13 | + | |
| 14 | +export const oauthProtectedResourceHandler = httpAction(async (_ctx, request) => { | |
| 15 | + const mcpAuthConfig = getMcpAuthConfig(); | |
| 16 | + const url = new URL(request.url); | |
| 17 | + | |
| 18 | + let resource = new URL("/mcp", url.origin); | |
| 19 | + resource.search = url.search; | |
| 20 | + const resourceHint = url.searchParams.get("resource"); | |
| 21 | + if (resourceHint) { | |
| 22 | + try { | |
| 23 | + const parsed = new URL(resourceHint); | |
| 24 | + if (parsed.origin === url.origin && parsed.pathname === "/mcp") { | |
| 25 | + resource = parsed; | |
| 26 | + } | |
| 27 | + } catch { | |
| 28 | + // Ignore malformed resource hint and fall back to request URL params. | |
| 29 | + } | |
| 30 | + } | |
| 31 | + | |
| 32 | + let provider = selectMcpAuthProvider(request, mcpAuthConfig); | |
| 33 | + const hintedSessionId = resource.searchParams.get("sessionId") ?? undefined; | |
| 34 | + const hintedWorkspaceId = resource.searchParams.get("workspaceId") ?? undefined; | |
| 35 | + | |
| 36 | + if (mcpAuthConfig.anonymousEnabled && isAnonymousSessionId(hintedSessionId)) { | |
| 37 | + provider = "anonymous"; | |
| 38 | + } | |
| 39 | + | |
| 40 | + if (!provider && mcpAuthConfig.anonymousEnabled) { | |
| 41 | + provider = "anonymous"; | |
| 42 | + } | |
| 43 | + | |
| 44 | + if (provider === "workos" && !hintedWorkspaceId && mcpAuthConfig.anonymousEnabled) { | |
| 45 | + // Queryless discovery calls are ambiguous; prefer anonymous so clients | |
| 46 | + // can discover a first-party auth server without being redirected to WorkOS. | |
| 47 | + provider = "anonymous"; | |
| 48 | + } | |
| 49 | + | |
| 50 | + if (!provider) { | |
| 51 | + return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 }); | |
| 52 | + } | |
| 53 | + | |
| 54 | + const authorizationServer = provider === "anonymous" | |
| 55 | + ? url.origin | |
| 56 | + : mcpAuthConfig.authorizationServer; | |
| 57 | + | |
| 58 | + if (!authorizationServer) { | |
| 59 | + return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 }); | |
| 60 | + } | |
| 61 | + | |
| 62 | + return Response.json({ | |
| 63 | + resource: resource.toString(), | |
| 64 | + authorization_servers: [authorizationServer], | |
| 65 | + bearer_methods_supported: ["header"], | |
| 66 | + }); | |
| 67 | +}); | |
| 68 | + | |
| 69 | +export const oauthAuthorizationServerHandler = httpAction(async (_ctx, request) => { | |
| 70 | + const mcpAuthConfig = getMcpAuthConfig(); | |
| 71 | + if (!mcpAuthConfig.enabled) { | |
| 72 | + return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 }); | |
| 73 | + } | |
| 74 | + | |
| 75 | + // Clients usually call this endpoint without workspace/session params. | |
| 76 | + // When anonymous OAuth is enabled, default to our self-issued metadata. | |
| 77 | + if (mcpAuthConfig.anonymousEnabled) { | |
| 78 | + const oauthServer = await getAnonymousOAuthServer(_ctx, request); | |
| 79 | + return Response.json(oauthServer.getMetadata()); | |
| 80 | + } | |
| 81 | + | |
| 82 | + if (!mcpAuthConfig.authorizationServer) { | |
| 83 | + return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 }); | |
| 84 | + } | |
| 85 | + | |
| 86 | + const upstream = new URL("/.well-known/oauth-authorization-server", mcpAuthConfig.authorizationServer); | |
| 87 | + const response = await fetch(upstream.toString(), { | |
| 88 | + headers: { accept: "application/json" }, | |
| 89 | + }); | |
| 90 | + | |
| 91 | + const text = await response.text(); | |
| 92 | + return new Response(text, { | |
| 93 | + status: response.status, | |
| 94 | + headers: { | |
| 95 | + "content-type": response.headers.get("content-type") ?? "application/json", | |
| 96 | + }, | |
| 97 | + }); | |
| 98 | +}); | |
| 99 | + | |
| 100 | +export const oauthJwksHandler = httpAction(async (_ctx, request) => { | |
| 101 | + const mcpAuthConfig = getMcpAuthConfig(); | |
| 102 | + if (!mcpAuthConfig.enabled) { | |
| 103 | + return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 }); | |
| 104 | + } | |
| 105 | + | |
| 106 | + const oauthServer = await getAnonymousOAuthServer(_ctx, request); | |
| 107 | + return Response.json(oauthServer.getJwks(), { | |
| 108 | + headers: { | |
| 109 | + "cache-control": "public, max-age=3600", | |
| 110 | + }, | |
| 111 | + }); | |
| 112 | +}); | |
| 113 | + | |
| 114 | +export const oauthRegisterHandler = httpAction(async (_ctx, request) => { | |
| 115 | + const mcpAuthConfig = getMcpAuthConfig(); | |
| 116 | + if (!mcpAuthConfig.enabled) { | |
| 117 | + return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 }); | |
| 118 | + } | |
| 119 | + | |
| 120 | + let body: { redirect_uris?: string[]; client_name?: string }; | |
| 121 | + try { | |
| 122 | + body = await request.json() as { redirect_uris?: string[]; client_name?: string }; | |
| 123 | + } catch { | |
| 124 | + return Response.json({ error: "invalid_client_metadata", error_description: "Invalid JSON body" }, { status: 400 }); | |
| 125 | + } | |
| 126 | + | |
| 127 | + try { | |
| 128 | + const oauthServer = await getAnonymousOAuthServer(_ctx, request); | |
| 129 | + const registration = await oauthServer.registerClient(body); | |
| 130 | + return Response.json(registration, { status: 201 }); | |
| 131 | + } catch (error) { | |
| 132 | + if (error instanceof OAuthBadRequest) { | |
| 133 | + return Response.json( | |
| 134 | + { error: "invalid_client_metadata", error_description: error.message }, | |
| 135 | + { status: 400 }, | |
| 136 | + ); | |
| 137 | + } | |
| 138 | + throw error; | |
| 139 | + } | |
| 140 | +}); | |
| 141 | + | |
| 142 | +export const oauthAuthorizeHandler = httpAction(async (ctx, request) => { | |
| 143 | + const mcpAuthConfig = getMcpAuthConfig(); | |
| 144 | + if (!mcpAuthConfig.enabled) { | |
| 145 | + return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 }); | |
| 146 | + } | |
| 147 | + | |
| 148 | + const oauthServer = await getAnonymousOAuthServer(ctx, request); | |
| 149 | + const url = new URL(request.url); | |
| 150 | + const anonymousContext = parseAnonymousAuthorizeContext(url.searchParams); | |
| 151 | + | |
| 152 | + try { | |
| 153 | + if (!anonymousContext) { | |
| 154 | + throw new OAuthBadRequest("resource must include workspaceId and anonymous sessionId"); | |
| 155 | + } | |
| 156 | + | |
| 157 | + const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, { | |
| 158 | + workspaceId: anonymousContext.workspaceId, | |
| 159 | + sessionId: anonymousContext.sessionId, | |
| 160 | + }); | |
| 161 | + | |
| 162 | + if (access.provider !== "anonymous") { | |
| 163 | + throw new OAuthBadRequest("Anonymous OAuth requires an anonymous session"); | |
| 164 | + } | |
| 165 | + | |
| 166 | + const { redirectTo } = await oauthServer.authorize(url.searchParams, { | |
| 167 | + actorId: access.actorId, | |
| 168 | + tokenClaims: { | |
| 169 | + [ANON_WORKSPACE_CLAIM]: anonymousContext.workspaceId, | |
| 170 | + [ANON_SESSION_CLAIM]: anonymousContext.sessionId, | |
| 171 | + }, | |
| 172 | + }); | |
| 173 | + | |
| 174 | + return Response.redirect(redirectTo, 302); | |
| 175 | + } catch (error) { | |
| 176 | + if (error instanceof OAuthBadRequest) { | |
| 177 | + return Response.json( | |
| 178 | + { error: "invalid_request", error_description: error.message }, | |
| 179 | + { status: 400 }, | |
| 180 | + ); | |
| 181 | + } | |
| 182 | + return Response.json( | |
| 183 | + { error: error instanceof Error ? error.message : "Workspace authorization failed" }, | |
| 184 | + { status: 403 }, | |
| 185 | + ); | |
| 186 | + } | |
| 187 | +}); | |
| 188 | + | |
| 189 | +export const oauthTokenHandler = httpAction(async (_ctx, request) => { | |
| 190 | + const mcpAuthConfig = getMcpAuthConfig(); | |
| 191 | + if (!mcpAuthConfig.enabled) { | |
| 192 | + return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 }); | |
| 193 | + } | |
| 194 | + | |
| 195 | + try { | |
| 196 | + const oauthServer = await getAnonymousOAuthServer(_ctx, request); | |
| 197 | + const body = new URLSearchParams(await request.text()); | |
| 198 | + const tokens = await oauthServer.exchangeToken(body); | |
| 199 | + return Response.json(tokens, { | |
| 200 | + headers: { | |
| 201 | + "cache-control": "no-store", | |
| 202 | + }, | |
| 203 | + }); | |
| 204 | + } catch (error) { | |
| 205 | + if (error instanceof OAuthBadRequest) { | |
| 206 | + return Response.json( | |
| 207 | + { error: "invalid_grant", error_description: error.message }, | |
| 208 | + { status: 400 }, | |
| 209 | + ); | |
| 210 | + } | |
| 211 | + throw error; | |
| 212 | + } | |
| 213 | +}); | |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | import { v } from "convex/values"; |
| 4 | 4 | import { internal } from "./_generated/api"; |
| 5 | 5 | import { internalAction } from "./_generated/server"; |
| 6 | −import { loadSourceDtsByUrlCached } from "../core/src/dts-loader"; | |
| 6 | +import { loadSourceDtsByUrl } from "../core/src/dts-loader"; | |
| 7 | 7 | import { generateToolDeclarations, typecheckCode } from "../core/src/typechecker"; |
| 8 | 8 | import type { ToolDescriptor } from "../core/src/types"; |
| 9 | 9 | |
| @@ -26,7 +26,7 @@ export const typecheckRunCodeInternal = internalAction({ · 16 unmodified lines | ||
| 26 | 26 | dtsUrls?: Record<string, string>; |
| 27 | 27 | }; |
| 28 | 28 | |
| 29 | − const sourceDtsBySource = await loadSourceDtsByUrlCached(result.dtsUrls ?? {}); | |
| 29 | + const sourceDtsBySource = await loadSourceDtsByUrl(result.dtsUrls ?? {}); | |
| 30 | 30 | const declarations = generateToolDeclarations(result.tools, { |
| 31 | 31 | sourceDtsBySource, |
| 32 | 32 | }); |
| @@ -32,42 +32,12 @@ export const handleToolCall = action({ · 31 unmodified lines | ||
| 32 | 32 | }, |
| 33 | 33 | }); |
| 34 | 34 | |
| 35 | −export const appendOutput = mutation({ | |
| 36 | − args: { | |
| 37 | − internalSecret: v.string(), | |
| 38 | − runId: v.string(), | |
| 39 | − stream: v.union(v.literal("stdout"), v.literal("stderr")), | |
| 40 | − line: v.string(), | |
| 41 | − timestamp: v.optional(v.number()), | |
| 42 | − }, | |
| 43 | − handler: async (ctx, args) => { | |
| 44 | − requireInternalSecret(args.internalSecret); | |
| 45 | − | |
| 46 | − const task = await ctx.runQuery(internal.database.getTask, { | |
| 47 | − taskId: args.runId, | |
| 48 | − }); | |
| 49 | − if (!task) { | |
| 50 | − return { ok: false as const, error: `Run not found: ${args.runId}` }; | |
| 51 | − } | |
| 52 | − | |
| 53 | − await ctx.runMutation(internal.executor.appendRuntimeOutput, { | |
| 54 | − runId: args.runId, | |
| 55 | − stream: args.stream, | |
| 56 | − line: args.line, | |
| 57 | − timestamp: args.timestamp, | |
| 58 | − }); | |
| 59 | − | |
| 60 | − return { ok: true as const }; | |
| 61 | − }, | |
| 62 | −}); | |
| 63 | − | |
| 64 | 35 | export const completeRun = mutation({ |
| 65 | 36 | args: { |
| 66 | 37 | internalSecret: v.string(), |
| 67 | 38 | runId: v.string(), |
| 68 | 39 | status: v.union(v.literal("completed"), v.literal("failed"), v.literal("timed_out"), v.literal("denied")), |
| 69 | − stdout: v.optional(v.string()), | |
| 70 | − stderr: v.optional(v.string()), | |
| 40 | + result: v.optional(v.any()), | |
| 71 | 41 | exitCode: v.optional(v.number()), |
| 72 | 42 | error: v.optional(v.string()), |
| 73 | 43 | durationMs: v.optional(v.number()), |
| @@ -75,11 +45,10 @@ export const completeRun = mutation({ · 1 unmodified line | ||
| 75 | 45 | handler: async (ctx, args) => { |
| 76 | 46 | requireInternalSecret(args.internalSecret); |
| 77 | 47 | |
| 78 | − return await ctx.runMutation(internal.executor.completeRuntimeRun, { | |
| 48 | + return await ctx.runMutation(internal.executor.completeRuntimeRun as any, { | |
| 79 | 49 | runId: args.runId, |
| 80 | 50 | status: args.status, |
| 81 | − stdout: args.stdout, | |
| 82 | − stderr: args.stderr, | |
| 51 | + result: args.result, | |
| 83 | 52 | exitCode: args.exitCode, |
| 84 | 53 | error: args.error, |
| 85 | 54 | durationMs: args.durationMs, |
| @@ -44,7 +44,7 @@ const toolCallStatus = v.union( · 43 unmodified lines | ||
| 44 | 44 | const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 45 | 45 | const credentialScope = v.union(v.literal("workspace"), v.literal("actor")); |
| 46 | 46 | const credentialProvider = v.union( |
| 47 | − v.literal("managed"), | |
| 47 | + v.literal("local-convex"), | |
| 48 | 48 | v.literal("workos-vault"), |
| 49 | 49 | ); |
| 50 | 50 | const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); |
| @@ -186,8 +186,7 @@ export default defineSchema({ · 135 unmodified lines | ||
| 186 | 186 | timeoutMs: v.number(), |
| 187 | 187 | metadata: v.any(), |
| 188 | 188 | error: v.optional(v.string()), |
| 189 | − stdout: v.optional(v.string()), | |
| 190 | − stderr: v.optional(v.string()), | |
| 189 | + result: v.optional(v.any()), | |
| 191 | 190 | exitCode: v.optional(v.number()), |
| 192 | 191 | createdAt: v.number(), |
| 193 | 192 | updatedAt: v.number(), |
| @@ -219,10 +218,8 @@ export default defineSchema({ · 25 unmodified lines | ||
| 219 | 218 | callId: v.string(), |
| 220 | 219 | workspaceId: v.id("workspaces"), |
| 221 | 220 | toolPath: v.string(), |
| 222 | − input: v.any(), | |
| 223 | 221 | status: toolCallStatus, |
| 224 | 222 | approvalId: v.optional(v.string()), |
| 225 | − output: v.optional(v.any()), | |
| 226 | 223 | error: v.optional(v.string()), |
| 227 | 224 | createdAt: v.number(), |
| 228 | 225 | updatedAt: v.number(), |
| @@ -258,18 +255,23 @@ export default defineSchema({ · 29 unmodified lines | ||
| 258 | 255 | .index("by_workspace_created", ["workspaceId", "createdAt"]), |
| 259 | 256 | |
| 260 | 257 | sourceCredentials: defineTable({ |
| 261 | − credentialId: v.string(), // domain ID: cred_<uuid> | |
| 258 | + bindingId: v.string(), // domain ID: bind_<uuid> | |
| 259 | + credentialId: v.string(), // domain ID: conn_<uuid> | |
| 262 | 260 | workspaceId: v.id("workspaces"), |
| 263 | 261 | sourceKey: v.string(), |
| 264 | 262 | scope: credentialScope, |
| 265 | 263 | actorId: v.string(), // account._id or anon_<uuid> (required for composite index) |
| 266 | 264 | provider: credentialProvider, |
| 267 | 265 | secretJson: v.any(), |
| 266 | + overridesJson: v.optional(v.any()), | |
| 267 | + boundAuthFingerprint: v.optional(v.string()), | |
| 268 | 268 | createdAt: v.number(), |
| 269 | 269 | updatedAt: v.number(), |
| 270 | 270 | }) |
| 271 | 271 | .index("by_workspace_created", ["workspaceId", "createdAt"]) |
| 272 | − .index("by_workspace_source_scope_actor", ["workspaceId", "sourceKey", "scope", "actorId"]), | |
| 272 | + .index("by_workspace_source_scope_actor", ["workspaceId", "sourceKey", "scope", "actorId"]) | |
| 273 | + .index("by_workspace_credential", ["workspaceId", "credentialId"]) | |
| 274 | + .index("by_binding_id", ["bindingId"]), | |
| 273 | 275 | |
| 274 | 276 | toolSources: defineTable({ |
| 275 | 277 | sourceId: v.string(), // domain ID: src_<uuid> |
| @@ -277,6 +279,8 @@ export default defineSchema({ · 1 unmodified line | ||
| 277 | 279 | name: v.string(), |
| 278 | 280 | type: toolSourceType, |
| 279 | 281 | config: v.any(), |
| 282 | + specHash: v.optional(v.string()), | |
| 283 | + authFingerprint: v.optional(v.string()), | |
| 280 | 284 | enabled: v.boolean(), |
| 281 | 285 | createdAt: v.number(), |
| 282 | 286 | updatedAt: v.number(), |
| @@ -355,4 +359,22 @@ export default defineSchema({ · 72 unmodified lines | ||
| 355 | 359 | createdAt: v.number(), |
| 356 | 360 | }) |
| 357 | 361 | .index("by_client_id", ["clientId"]), |
| 362 | + | |
| 363 | + /** | |
| 364 | + * Authorization codes for anonymous OAuth (short-lived, single-use). | |
| 365 | + * Stored in Convex so code exchange survives function restarts. | |
| 366 | + */ | |
| 367 | + anonymousOauthCodes: defineTable({ | |
| 368 | + code: v.string(), | |
| 369 | + clientId: v.string(), | |
| 370 | + redirectUri: v.string(), | |
| 371 | + codeChallenge: v.string(), | |
| 372 | + codeChallengeMethod: v.string(), | |
| 373 | + actorId: v.string(), | |
| 374 | + tokenClaims: v.optional(v.any()), | |
| 375 | + expiresAt: v.number(), | |
| 376 | + createdAt: v.number(), | |
| 377 | + }) | |
| 378 | + .index("by_code", ["code"]) | |
| 379 | + .index("by_expires_at", ["expiresAt"]), | |
| 358 | 380 | }); |
| @@ -5,7 +5,7 @@ import { workspaceMutation, workspaceQuery } from "../core/src/function-builders · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 7 | 7 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); |
| 8 | −const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault")); | |
| 8 | +const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault")); | |
| 9 | 9 | const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); |
| 10 | 10 | |
| 11 | 11 | function redactCredential<T extends { secretJson: Record<string, unknown> }>(credential: T): T { |
| @@ -110,6 +110,7 @@ export const upsertCredential = workspaceMutation({ · 98 unmodified lines | ||
| 110 | 110 | actorId: v.optional(v.string()), |
| 111 | 111 | provider: v.optional(credentialProviderValidator), |
| 112 | 112 | secretJson: v.any(), |
| 113 | + overridesJson: v.optional(v.any()), | |
| 113 | 114 | }, |
| 114 | 115 | handler: async (ctx, args) => { |
| 115 | 116 | return await ctx.runMutation(internal.database.upsertCredential, { |
| @@ -10,7 +10,6 @@ test("returns run mismatch without invoking tool", async () => { · 9 unmodified lines | ||
| 10 | 10 | called += 1; |
| 11 | 11 | return { ok: true }; |
| 12 | 12 | }, |
| 13 | − emitOutput: () => {}, | |
| 14 | 13 | }); |
| 15 | 14 | |
| 16 | 15 | const result = await adapter.invokeTool({ |
| @@ -33,7 +32,6 @@ test("maps approval denied errors to denied result", async () => { · 16 unmodified lines | ||
| 33 | 32 | invokeTool: async () => { |
| 34 | 33 | throw new Error(`${APPROVAL_DENIED_PREFIX}approval required`); |
| 35 | 34 | }, |
| 36 | − emitOutput: () => {}, | |
| 37 | 35 | }); |
| 38 | 36 | |
| 39 | 37 | const result = await adapter.invokeTool({ |
| @@ -49,30 +47,3 @@ test("maps approval denied errors to denied result", async () => { · 9 unmodified lines | ||
| 49 | 47 | error: "approval required", |
| 50 | 48 | }); |
| 51 | 49 | }); |
| 52 | − | |
| 53 | −test("emits output only for matching run id", () => { | |
| 54 | − const lines: string[] = []; | |
| 55 | − const adapter = new InProcessExecutionAdapter({ | |
| 56 | − runId: "run_1", | |
| 57 | − invokeTool: async () => null, | |
| 58 | − emitOutput: (event) => { | |
| 59 | − lines.push(`${event.stream}:${event.line}`); | |
| 60 | − }, | |
| 61 | − }); | |
| 62 | − | |
| 63 | − adapter.emitOutput({ | |
| 64 | − runId: "run_other", | |
| 65 | − stream: "stdout", | |
| 66 | − line: "ignored", | |
| 67 | − timestamp: Date.now(), | |
| 68 | − }); | |
| 69 | − | |
| 70 | − adapter.emitOutput({ | |
| 71 | − runId: "run_1", | |
| 72 | − stream: "stdout", | |
| 73 | − line: "accepted", | |
| 74 | − timestamp: Date.now(), | |
| 75 | − }); | |
| 76 | − | |
| 77 | − expect(lines).toEqual(["stdout:accepted"]); | |
| 78 | −}); | |
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../execution-constants"; |
| 2 | 2 | import type { |
| 3 | 3 | ExecutionAdapter, |
| 4 | − RuntimeOutputEvent, | |
| 5 | 4 | ToolCallRequest, |
| 6 | 5 | ToolCallResult, |
| 7 | 6 | } from "../types"; |
| @@ -10,7 +9,6 @@ import { describeError } from "../utils"; · 2 unmodified lines | ||
| 10 | 9 | interface InProcessExecutionAdapterOptions { |
| 11 | 10 | runId: string; |
| 12 | 11 | invokeTool: (call: ToolCallRequest) => Promise<unknown>; |
| 13 | − emitOutput: (event: RuntimeOutputEvent) => void; | |
| 14 | 12 | } |
| 15 | 13 | |
| 16 | 14 | export class InProcessExecutionAdapter implements ExecutionAdapter { |
| @@ -55,11 +53,4 @@ export class InProcessExecutionAdapter implements ExecutionAdapter { · 38 unmodified lines | ||
| 55 | 53 | }; |
| 56 | 54 | } |
| 57 | 55 | } |
| 58 | − | |
| 59 | − emitOutput(event: RuntimeOutputEvent): void { | |
| 60 | − if (event.runId !== this.options.runId) { | |
| 61 | − return; | |
| 62 | − } | |
| 63 | − this.options.emitOutput(event); | |
| 64 | − } | |
| 65 | 56 | } |
| @@ -11,7 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | // --------------------------------------------------------------------------- |
| 12 | 12 | |
| 13 | 13 | function createServer(issuer = "http://localhost:3003"): AnonymousOAuthServer { |
| 14 | − return new AnonymousOAuthServer({ issuer }); | |
| 14 | + return new AnonymousOAuthServer({ issuer, storage: new InMemoryOAuthStorage() }); | |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | 17 | /** Generate a random PKCE code_verifier (43–128 chars, unreserved). */ |
| @@ -253,6 +253,85 @@ describe("AnonymousOAuthServer", () => { · 235 unmodified lines | ||
| 253 | 253 | expect(verified!.provider).toBe("anonymous"); |
| 254 | 254 | }); |
| 255 | 255 | |
| 256 | + test("supports binding a specific anonymous actor subject", async () => { | |
| 257 | + const redirectUri = "http://localhost:9999/callback"; | |
| 258 | + const client = await server.registerClient({ | |
| 259 | + redirect_uris: [redirectUri], | |
| 260 | + }); | |
| 261 | + | |
| 262 | + const codeVerifier = generateCodeVerifier(); | |
| 263 | + const codeChallenge = await computeS256Challenge(codeVerifier); | |
| 264 | + const actorId = `anon_${crypto.randomUUID()}`; | |
| 265 | + | |
| 266 | + const params = new URLSearchParams({ | |
| 267 | + response_type: "code", | |
| 268 | + client_id: client.client_id, | |
| 269 | + redirect_uri: redirectUri, | |
| 270 | + code_challenge: codeChallenge, | |
| 271 | + code_challenge_method: "S256", | |
| 272 | + }); | |
| 273 | + | |
| 274 | + const { redirectTo } = await server.authorize(params, { actorId }); | |
| 275 | + const code = new URL(redirectTo).searchParams.get("code")!; | |
| 276 | + | |
| 277 | + const { access_token } = await server.exchangeToken( | |
| 278 | + new URLSearchParams({ | |
| 279 | + grant_type: "authorization_code", | |
| 280 | + code, | |
| 281 | + client_id: client.client_id, | |
| 282 | + redirect_uri: redirectUri, | |
| 283 | + code_verifier: codeVerifier, | |
| 284 | + }), | |
| 285 | + ); | |
| 286 | + | |
| 287 | + const verified = await server.verifyToken(access_token); | |
| 288 | + expect(verified).not.toBeNull(); | |
| 289 | + expect(verified!.sub).toBe(actorId); | |
| 290 | + }); | |
| 291 | + | |
| 292 | + test("includes custom claims and filters reserved JWT claims", async () => { | |
| 293 | + const redirectUri = "http://localhost:9999/callback"; | |
| 294 | + const client = await server.registerClient({ | |
| 295 | + redirect_uris: [redirectUri], | |
| 296 | + }); | |
| 297 | + | |
| 298 | + const codeVerifier = generateCodeVerifier(); | |
| 299 | + const codeChallenge = await computeS256Challenge(codeVerifier); | |
| 300 | + const params = new URLSearchParams({ | |
| 301 | + response_type: "code", | |
| 302 | + client_id: client.client_id, | |
| 303 | + redirect_uri: redirectUri, | |
| 304 | + code_challenge: codeChallenge, | |
| 305 | + code_challenge_method: "S256", | |
| 306 | + }); | |
| 307 | + | |
| 308 | + const { redirectTo } = await server.authorize(params, { | |
| 309 | + tokenClaims: { | |
| 310 | + workspace_id: "ws_123", | |
| 311 | + session_id: "mcp_session_123", | |
| 312 | + sub: "ignored", | |
| 313 | + iss: "ignored", | |
| 314 | + }, | |
| 315 | + }); | |
| 316 | + const code = new URL(redirectTo).searchParams.get("code")!; | |
| 317 | + | |
| 318 | + const { access_token } = await server.exchangeToken( | |
| 319 | + new URLSearchParams({ | |
| 320 | + grant_type: "authorization_code", | |
| 321 | + code, | |
| 322 | + client_id: client.client_id, | |
| 323 | + redirect_uri: redirectUri, | |
| 324 | + code_verifier: codeVerifier, | |
| 325 | + }), | |
| 326 | + ); | |
| 327 | + | |
| 328 | + const verified = await server.verifyToken(access_token); | |
| 329 | + expect(verified).not.toBeNull(); | |
| 330 | + expect(verified!.claims.workspace_id).toBe("ws_123"); | |
| 331 | + expect(verified!.claims.session_id).toBe("mcp_session_123"); | |
| 332 | + expect(verified!.sub).toStartWith("anon_"); | |
| 333 | + }); | |
| 334 | + | |
| 256 | 335 | test("code is single-use", async () => { |
| 257 | 336 | const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(server); |
| 258 | 337 | |
| @@ -298,6 +377,7 @@ describe("AnonymousOAuthServer", () => { · 39 unmodified lines | ||
| 298 | 377 | const shortServer = new AnonymousOAuthServer({ |
| 299 | 378 | issuer: "http://localhost:3003", |
| 300 | 379 | codeExpirySeconds: 0, // expires immediately |
| 380 | + storage: new InMemoryOAuthStorage(), | |
| 301 | 381 | }); |
| 302 | 382 | await shortServer.init(); |
| 303 | 383 | |
| @@ -433,20 +513,21 @@ describe("AnonymousOAuthServer", () => { · 129 unmodified lines | ||
| 433 | 513 | const s = new AnonymousOAuthServer({ |
| 434 | 514 | issuer: "http://localhost:3003", |
| 435 | 515 | codeExpirySeconds: 0, |
| 516 | + storage: new InMemoryOAuthStorage(), | |
| 436 | 517 | }); |
| 437 | 518 | await s.init(); |
| 438 | 519 | |
| 439 | 520 | // Generate some codes |
| 440 | 521 | await registerAndAuthorize(s); |
| 441 | 522 | await registerAndAuthorize(s); |
| 442 | − expect(s.getCodeCount()).toBe(2); | |
| 523 | + expect(await s.getCodeCount()).toBe(2); | |
| 443 | 524 | |
| 444 | 525 | // Wait for them to expire |
| 445 | 526 | await new Promise((resolve) => setTimeout(resolve, 10)); |
| 446 | 527 | |
| 447 | − const purged = s.purgeExpiredCodes(); | |
| 528 | + const purged = await s.purgeExpiredCodes(); | |
| 448 | 529 | expect(purged).toBe(2); |
| 449 | − expect(s.getCodeCount()).toBe(0); | |
| 530 | + expect(await s.getCodeCount()).toBe(0); | |
| 450 | 531 | }); |
| 451 | 532 | }); |
| 452 | 533 | |
| @@ -458,13 +539,14 @@ describe("AnonymousOAuthServer", () => { · 5 unmodified lines | ||
| 458 | 539 | issuer: "http://localhost:3003", |
| 459 | 540 | maxPendingCodes: 2, |
| 460 | 541 | codeExpirySeconds: 300, // long-lived so they don't auto-purge |
| 542 | + storage: new InMemoryOAuthStorage(), | |
| 461 | 543 | }); |
| 462 | 544 | await s.init(); |
| 463 | 545 | |
| 464 | 546 | // Fill up the code slots |
| 465 | 547 | await registerAndAuthorize(s); |
| 466 | 548 | await registerAndAuthorize(s); |
| 467 | − expect(s.getCodeCount()).toBe(2); | |
| 549 | + expect(await s.getCodeCount()).toBe(2); | |
| 468 | 550 | |
| 469 | 551 | // Third should be rejected |
| 470 | 552 | await expect(registerAndAuthorize(s)).rejects.toThrow( |
| @@ -477,13 +559,14 @@ describe("AnonymousOAuthServer", () => { · 6 unmodified lines | ||
| 477 | 559 | issuer: "http://localhost:3003", |
| 478 | 560 | maxPendingCodes: 2, |
| 479 | 561 | codeExpirySeconds: 0, // codes expire immediately |
| 562 | + storage: new InMemoryOAuthStorage(), | |
| 480 | 563 | }); |
| 481 | 564 | await s.init(); |
| 482 | 565 | |
| 483 | 566 | // Fill slots (they expire immediately) |
| 484 | 567 | await registerAndAuthorize(s); |
| 485 | 568 | await registerAndAuthorize(s); |
| 486 | − expect(s.getCodeCount()).toBe(2); | |
| 569 | + expect(await s.getCodeCount()).toBe(2); | |
| 487 | 570 | |
| 488 | 571 | // Wait for expiry |
| 489 | 572 | await new Promise((resolve) => setTimeout(resolve, 10)); |
| @@ -21,10 +21,8 @@ · 20 unmodified lines | ||
| 21 | 21 | * |
| 22 | 22 | * Storage is pluggable via the `OAuthStorage` interface: |
| 23 | 23 | * - `InMemoryOAuthStorage` — ephemeral, for tests and single-process dev |
| 24 | − * - `ConvexOAuthStorage` — persists keys & client registrations to Convex, | |
| 24 | + * - `ConvexOAuthStorage` — persists keys, client registrations, and auth codes to Convex, | |
| 25 | 25 | * so tokens survive gateway restarts |
| 26 | − * | |
| 27 | − * Authorization codes are always in-memory (short-lived, single-use). | |
| 28 | 26 | */ |
| 29 | 27 | |
| 30 | 28 | import { |
| @@ -36,7 +34,6 @@ import { · 5 unmodified lines | ||
| 36 | 34 | jwtVerify, |
| 37 | 35 | type JWK, |
| 38 | 36 | type CryptoKey as JoseCryptoKey, |
| 39 | − type JWTVerifyGetKey, | |
| 40 | 37 | } from "jose"; |
| 41 | 38 | |
| 42 | 39 | // --------------------------------------------------------------------------- |
| @@ -57,7 +54,39 @@ interface AuthorizationCode { · 14 unmodified lines | ||
| 57 | 54 | codeChallenge: string; |
| 58 | 55 | codeChallengeMethod: string; |
| 59 | 56 | actorId: string; |
| 57 | + tokenClaims?: Record<string, unknown>; | |
| 60 | 58 | expiresAt: number; |
| 59 | + createdAt: number; | |
| 60 | +} | |
| 61 | + | |
| 62 | +const RESERVED_JWT_CLAIMS = new Set([ | |
| 63 | + "iss", | |
| 64 | + "sub", | |
| 65 | + "aud", | |
| 66 | + "exp", | |
| 67 | + "nbf", | |
| 68 | + "iat", | |
| 69 | + "jti", | |
| 70 | +]); | |
| 71 | + | |
| 72 | +function sanitizeTokenClaims(input?: Record<string, unknown>): Record<string, unknown> | undefined { | |
| 73 | + if (!input) return undefined; | |
| 74 | + | |
| 75 | + const sanitized: Record<string, unknown> = {}; | |
| 76 | + for (const [key, value] of Object.entries(input)) { | |
| 77 | + if (RESERVED_JWT_CLAIMS.has(key)) continue; | |
| 78 | + if (value === undefined) continue; | |
| 79 | + sanitized[key] = value; | |
| 80 | + } | |
| 81 | + | |
| 82 | + return Object.keys(sanitized).length > 0 ? sanitized : undefined; | |
| 83 | +} | |
| 84 | + | |
| 85 | +export interface AnonymousAuthorizeOptions { | |
| 86 | + /** Optional actor subject override. Defaults to a fresh `anon_<uuid>`. */ | |
| 87 | + actorId?: string; | |
| 88 | + /** Optional custom access-token claims (reserved JWT claims are ignored). */ | |
| 89 | + tokenClaims?: Record<string, unknown>; | |
| 61 | 90 | } |
| 62 | 91 | |
| 63 | 92 | export interface AnonOAuthConfig { |
| @@ -69,8 +98,8 @@ export interface AnonOAuthConfig { · 5 unmodified lines | ||
| 69 | 98 | codeExpirySeconds?: number; |
| 70 | 99 | /** Maximum number of in-flight authorization codes. Default 10 000. */ |
| 71 | 100 | maxPendingCodes?: number; |
| 72 | − /** Pluggable storage backend for keys & client registrations. */ | |
| 73 | − storage?: OAuthStorage; | |
| 101 | + /** Pluggable storage backend for keys, clients, and authorization codes. */ | |
| 102 | + storage: OAuthStorage; | |
| 74 | 103 | } |
| 75 | 104 | |
| 76 | 105 | // --------------------------------------------------------------------------- |
| @@ -105,6 +134,26 @@ export interface OAuthStorage { · 28 unmodified lines | ||
| 105 | 134 | * Look up an OAuth client by client_id. Returns null if not found. |
| 106 | 135 | */ |
| 107 | 136 | getClient(clientId: string): Promise<AnonOAuthClientRegistration | null>; |
| 137 | + | |
| 138 | + /** | |
| 139 | + * Persist an authorization code. | |
| 140 | + */ | |
| 141 | + storeAuthorizationCode(code: AuthorizationCode): Promise<void>; | |
| 142 | + | |
| 143 | + /** | |
| 144 | + * Atomically consume and return an authorization code. | |
| 145 | + */ | |
| 146 | + consumeAuthorizationCode(code: string): Promise<AuthorizationCode | null>; | |
| 147 | + | |
| 148 | + /** | |
| 149 | + * Purge expired authorization codes. | |
| 150 | + */ | |
| 151 | + purgeExpiredAuthorizationCodes(now: number): Promise<number>; | |
| 152 | + | |
| 153 | + /** | |
| 154 | + * Return the number of pending authorization codes. | |
| 155 | + */ | |
| 156 | + countAuthorizationCodes(): Promise<number>; | |
| 108 | 157 | } |
| 109 | 158 | |
| 110 | 159 | // --------------------------------------------------------------------------- |
| @@ -114,6 +163,7 @@ export interface OAuthStorage { · 3 unmodified lines | ||
| 114 | 163 | export class InMemoryOAuthStorage implements OAuthStorage { |
| 115 | 164 | private signingKey: StoredSigningKey | null = null; |
| 116 | 165 | private readonly clients = new Map<string, AnonOAuthClientRegistration>(); |
| 166 | + private readonly authorizationCodes = new Map<string, AuthorizationCode>(); | |
| 117 | 167 | |
| 118 | 168 | async getActiveSigningKey(): Promise<StoredSigningKey | null> { |
| 119 | 169 | return this.signingKey; |
| @@ -132,6 +182,34 @@ export class InMemoryOAuthStorage implements OAuthStorage { · 12 unmodified lines | ||
| 132 | 182 | return this.clients.get(clientId) ?? null; |
| 133 | 183 | } |
| 134 | 184 | |
| 185 | + async storeAuthorizationCode(code: AuthorizationCode): Promise<void> { | |
| 186 | + this.authorizationCodes.set(code.code, code); | |
| 187 | + } | |
| 188 | + | |
| 189 | + async consumeAuthorizationCode(code: string): Promise<AuthorizationCode | null> { | |
| 190 | + const existing = this.authorizationCodes.get(code) ?? null; | |
| 191 | + if (!existing) { | |
| 192 | + return null; | |
| 193 | + } | |
| 194 | + this.authorizationCodes.delete(code); | |
| 195 | + return existing; | |
| 196 | + } | |
| 197 | + | |
| 198 | + async purgeExpiredAuthorizationCodes(now: number): Promise<number> { | |
| 199 | + let purged = 0; | |
| 200 | + for (const [key, code] of this.authorizationCodes) { | |
| 201 | + if (now > code.expiresAt) { | |
| 202 | + this.authorizationCodes.delete(key); | |
| 203 | + purged += 1; | |
| 204 | + } | |
| 205 | + } | |
| 206 | + return purged; | |
| 207 | + } | |
| 208 | + | |
| 209 | + async countAuthorizationCodes(): Promise<number> { | |
| 210 | + return this.authorizationCodes.size; | |
| 211 | + } | |
| 212 | + | |
| 135 | 213 | /** Test helper: number of registered clients. */ |
| 136 | 214 | get clientCount(): number { |
| 137 | 215 | return this.clients.size; |
| @@ -153,11 +231,6 @@ export class AnonymousOAuthServer { · 15 unmodified lines | ||
| 153 | 231 | private privateKey!: JoseCryptoKey; |
| 154 | 232 | private publicJwk!: JWK; |
| 155 | 233 | private keyId!: string; |
| 156 | − /** Cached local JWKS for fast token verification (built once on init). */ | |
| 157 | − private localJwks!: JWTVerifyGetKey; | |
| 158 | − | |
| 159 | − /** In-memory authorization codes (short-lived, cleaned up on use). */ | |
| 160 | − private readonly codes = new Map<string, AuthorizationCode>(); | |
| 161 | 234 | |
| 162 | 235 | constructor(config: AnonOAuthConfig) { |
| 163 | 236 | this.issuer = config.issuer.replace(/\/+$/, ""); |
| @@ -165,7 +238,7 @@ export class AnonymousOAuthServer { · 1 unmodified line | ||
| 165 | 238 | this.accessTokenTtlSeconds = config.accessTokenTtlSeconds ?? 24 * 60 * 60; |
| 166 | 239 | this.codeExpirySeconds = config.codeExpirySeconds ?? 120; |
| 167 | 240 | this.maxPendingCodes = config.maxPendingCodes ?? 10_000; |
| 168 | − this.storage = config.storage ?? new InMemoryOAuthStorage(); | |
| 241 | + this.storage = config.storage; | |
| 169 | 242 | } |
| 170 | 243 | |
| 171 | 244 | // ------------------------------------------------------------------------- |
| @@ -176,8 +249,7 @@ export class AnonymousOAuthServer { · 4 unmodified lines | ||
| 176 | 249 | * Initialize the OAuth server. |
| 177 | 250 | * |
| 178 | 251 | * Attempts to load an existing signing key from storage. If none exists, |
| 179 | − * generates a new RSA key pair and persists it. Either way, the private | |
| 180 | − * key and public JWK are cached locally for fast in-process operations. | |
| 252 | + * generates a new RSA key pair and persists it. | |
| 181 | 253 | */ |
| 182 | 254 | async init(): Promise<void> { |
| 183 | 255 | const existing = await this.storage.getActiveSigningKey(); |
| @@ -205,9 +277,6 @@ export class AnonymousOAuthServer { · 21 unmodified lines | ||
| 205 | 277 | publicKeyJwk: publicJwk, |
| 206 | 278 | }); |
| 207 | 279 | } |
| 208 | − | |
| 209 | − // Build cached local JWKS for fast token verification | |
| 210 | − this.localJwks = createLocalJWKSet({ keys: [this.publicJwk] }); | |
| 211 | 280 | } |
| 212 | 281 | |
| 213 | 282 | /** |
| @@ -218,7 +287,6 @@ export class AnonymousOAuthServer { · 4 unmodified lines | ||
| 218 | 287 | this.privateKey = privateKey; |
| 219 | 288 | this.keyId = publicJwk.kid ?? `anon_key_${crypto.randomUUID().slice(0, 8)}`; |
| 220 | 289 | this.publicJwk = { ...publicJwk, kid: this.keyId, use: "sig", alg: "RS256" }; |
| 221 | − this.localJwks = createLocalJWKSet({ keys: [this.publicJwk] }); | |
| 222 | 290 | } |
| 223 | 291 | |
| 224 | 292 | // ------------------------------------------------------------------------- |
| @@ -292,7 +360,7 @@ export class AnonymousOAuthServer { · 67 unmodified lines | ||
| 292 | 360 | * user to authenticate — we auto-approve and redirect back immediately |
| 293 | 361 | * with an authorization code. |
| 294 | 362 | */ |
| 295 | − async authorize(params: URLSearchParams): Promise<{ redirectTo: string }> { | |
| 363 | + async authorize(params: URLSearchParams, options?: AnonymousAuthorizeOptions): Promise<{ redirectTo: string }> { | |
| 296 | 364 | const responseType = params.get("response_type"); |
| 297 | 365 | if (responseType !== "code") { |
| 298 | 366 | throw new OAuthBadRequest("response_type must be 'code'"); |
| @@ -322,28 +390,32 @@ export class AnonymousOAuthServer { · 23 unmodified lines | ||
| 322 | 390 | throw new OAuthBadRequest("PKCE S256 code_challenge is required"); |
| 323 | 391 | } |
| 324 | 392 | |
| 325 | − // Generate anonymous identity | |
| 326 | − const actorId = `anon_${crypto.randomUUID()}`; | |
| 393 | + // Generate or bind anonymous identity | |
| 394 | + const requestedActorId = options?.actorId?.trim(); | |
| 395 | + const actorId = requestedActorId && requestedActorId.length > 0 | |
| 396 | + ? requestedActorId | |
| 397 | + : `anon_${crypto.randomUUID()}`; | |
| 327 | 398 | |
| 328 | − // Enforce cap on pending codes to prevent memory exhaustion | |
| 329 | − if (this.codes.size >= this.maxPendingCodes) { | |
| 330 | − // Purge expired codes first; if still over limit, reject | |
| 331 | − this.purgeExpiredCodes(); | |
| 332 | − if (this.codes.size >= this.maxPendingCodes) { | |
| 399 | + // Enforce cap on pending codes to prevent unbounded growth | |
| 400 | + if (await this.storage.countAuthorizationCodes() >= this.maxPendingCodes) { | |
| 401 | + await this.storage.purgeExpiredAuthorizationCodes(Date.now()); | |
| 402 | + if (await this.storage.countAuthorizationCodes() >= this.maxPendingCodes) { | |
| 333 | 403 | throw new OAuthBadRequest("Too many pending authorization requests — try again later"); |
| 334 | 404 | } |
| 335 | 405 | } |
| 336 | 406 | |
| 337 | − // Issue authorization code (always in-memory — short-lived, single-use) | |
| 407 | + // Issue authorization code (persistent, short-lived, single-use) | |
| 338 | 408 | const code = crypto.randomUUID(); |
| 339 | − this.codes.set(code, { | |
| 409 | + await this.storage.storeAuthorizationCode({ | |
| 340 | 410 | code, |
| 341 | 411 | clientId, |
| 342 | 412 | redirectUri, |
| 343 | 413 | codeChallenge, |
| 344 | 414 | codeChallengeMethod, |
| 345 | 415 | actorId, |
| 416 | + tokenClaims: sanitizeTokenClaims(options?.tokenClaims), | |
| 346 | 417 | expiresAt: Date.now() + this.codeExpirySeconds * 1000, |
| 418 | + createdAt: Date.now(), | |
| 347 | 419 | }); |
| 348 | 420 | |
| 349 | 421 | // Build redirect URL |
| @@ -380,14 +452,11 @@ export class AnonymousOAuthServer { · 30 unmodified lines | ||
| 380 | 452 | throw new OAuthBadRequest("code is required"); |
| 381 | 453 | } |
| 382 | 454 | |
| 383 | − const storedCode = this.codes.get(codeValue); | |
| 455 | + const storedCode = await this.storage.consumeAuthorizationCode(codeValue); | |
| 384 | 456 | if (!storedCode) { |
| 385 | 457 | throw new OAuthBadRequest("invalid or expired code"); |
| 386 | 458 | } |
| 387 | 459 | |
| 388 | − // Consume the code immediately (one-time use) | |
| 389 | − this.codes.delete(codeValue); | |
| 390 | − | |
| 391 | 460 | if (Date.now() > storedCode.expiresAt) { |
| 392 | 461 | throw new OAuthBadRequest("authorization code has expired"); |
| 393 | 462 | } |
| @@ -416,11 +485,14 @@ export class AnonymousOAuthServer { · 22 unmodified lines | ||
| 416 | 485 | } |
| 417 | 486 | |
| 418 | 487 | // Mint JWT |
| 419 | − const accessToken = await new SignJWT({ | |
| 420 | − sub: storedCode.actorId, | |
| 488 | + const accessTokenClaims = { | |
| 421 | 489 | provider: "anonymous", |
| 422 | − }) | |
| 490 | + ...(storedCode.tokenClaims ?? {}), | |
| 491 | + }; | |
| 492 | + | |
| 493 | + const accessToken = await new SignJWT(accessTokenClaims) | |
| 423 | 494 | .setProtectedHeader({ alg: "RS256", kid: this.keyId }) |
| 495 | + .setSubject(storedCode.actorId) | |
| 424 | 496 | .setIssuer(this.issuer) |
| 425 | 497 | .setAudience(this.audience) |
| 426 | 498 | .setIssuedAt() |
| @@ -441,9 +513,9 @@ export class AnonymousOAuthServer { · 14 unmodified lines | ||
| 441 | 513 | |
| 442 | 514 | async verifyToken( |
| 443 | 515 | token: string, |
| 444 | − ): Promise<{ sub: string; provider: string } | null> { | |
| 516 | + ): Promise<{ sub: string; provider: string; claims: Record<string, unknown> } | null> { | |
| 445 | 517 | try { |
| 446 | − const { payload } = await jwtVerify(token, this.localJwks, { | |
| 518 | + const { payload } = await jwtVerify(token, createLocalJWKSet({ keys: [this.publicJwk] }), { | |
| 447 | 519 | issuer: this.issuer, |
| 448 | 520 | audience: this.audience, |
| 449 | 521 | }); |
| @@ -455,6 +527,9 @@ export class AnonymousOAuthServer { · 5 unmodified lines | ||
| 455 | 527 | return { |
| 456 | 528 | sub: payload.sub, |
| 457 | 529 | provider: typeof payload.provider === "string" ? payload.provider : "anonymous", |
| 530 | + claims: Object.fromEntries( | |
| 531 | + Object.entries(payload).filter(([key]) => !RESERVED_JWT_CLAIMS.has(key) && key !== "sub"), | |
| 532 | + ), | |
| 458 | 533 | }; |
| 459 | 534 | } catch { |
| 460 | 535 | return null; |
| @@ -469,21 +544,13 @@ export class AnonymousOAuthServer { · 8 unmodified lines | ||
| 469 | 544 | return this.issuer; |
| 470 | 545 | } |
| 471 | 546 | |
| 472 | − getCodeCount(): number { | |
| 473 | − return this.codes.size; | |
| 547 | + async getCodeCount(): Promise<number> { | |
| 548 | + return await this.storage.countAuthorizationCodes(); | |
| 474 | 549 | } |
| 475 | 550 | |
| 476 | 551 | /** Purge expired authorization codes (call periodically). */ |
| 477 | − purgeExpiredCodes(): number { | |
| 478 | − const now = Date.now(); | |
| 479 | − let purged = 0; | |
| 480 | − for (const [key, code] of this.codes) { | |
| 481 | − if (now > code.expiresAt) { | |
| 482 | − this.codes.delete(key); | |
| 483 | − purged++; | |
| 484 | − } | |
| 485 | − } | |
| 486 | − return purged; | |
| 552 | + async purgeExpiredCodes(): Promise<number> { | |
| 553 | + return await this.storage.purgeExpiredAuthorizationCodes(Date.now()); | |
| 487 | 554 | } |
| 488 | 555 | } |
| 489 | 556 | |
| @@ -1,9 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Convex-backed storage for the anonymous OAuth server. |
| 3 | 3 | * |
| 4 | − * Persists signing keys and client registrations to Convex so they | |
| 5 | − * survive gateway restarts. Keys are cached locally after first load | |
| 6 | − * for fast in-process token verification. | |
| 4 | + * Persists signing keys, client registrations, and authorization codes | |
| 5 | + * to Convex so OAuth flows survive gateway restarts. | |
| 7 | 6 | * |
| 8 | 7 | * All Convex calls include an `internalSecret` argument that is validated |
| 9 | 8 | * server-side against `EXECUTOR_INTERNAL_TOKEN`, preventing unauthorized |
| @@ -97,4 +96,69 @@ export class ConvexOAuthStorage implements OAuthStorage { · 87 unmodified lines | ||
| 97 | 96 | created_at: result.created_at, |
| 98 | 97 | }; |
| 99 | 98 | } |
| 99 | + | |
| 100 | + async storeAuthorizationCode(code: { | |
| 101 | + code: string; | |
| 102 | + clientId: string; | |
| 103 | + redirectUri: string; | |
| 104 | + codeChallenge: string; | |
| 105 | + codeChallengeMethod: string; | |
| 106 | + actorId: string; | |
| 107 | + tokenClaims?: Record<string, unknown>; | |
| 108 | + expiresAt: number; | |
| 109 | + createdAt: number; | |
| 110 | + }): Promise<void> { | |
| 111 | + await this.convex.mutation(api.anonymousOauth.storeAuthorizationCode, { | |
| 112 | + internalSecret: this.internalSecret, | |
| 113 | + ...code, | |
| 114 | + }); | |
| 115 | + } | |
| 116 | + | |
| 117 | + async consumeAuthorizationCode(code: string): Promise<{ | |
| 118 | + code: string; | |
| 119 | + clientId: string; | |
| 120 | + redirectUri: string; | |
| 121 | + codeChallenge: string; | |
| 122 | + codeChallengeMethod: string; | |
| 123 | + actorId: string; | |
| 124 | + tokenClaims?: Record<string, unknown>; | |
| 125 | + expiresAt: number; | |
| 126 | + createdAt: number; | |
| 127 | + } | null> { | |
| 128 | + const result = await this.convex.mutation(api.anonymousOauth.consumeAuthorizationCode, { | |
| 129 | + internalSecret: this.internalSecret, | |
| 130 | + code, | |
| 131 | + }); | |
| 132 | + | |
| 133 | + if (!result) { | |
| 134 | + return null; | |
| 135 | + } | |
| 136 | + | |
| 137 | + return { | |
| 138 | + code: result.code, | |
| 139 | + clientId: result.clientId, | |
| 140 | + redirectUri: result.redirectUri, | |
| 141 | + codeChallenge: result.codeChallenge, | |
| 142 | + codeChallengeMethod: result.codeChallengeMethod, | |
| 143 | + actorId: result.actorId, | |
| 144 | + tokenClaims: result.tokenClaims ?? undefined, | |
| 145 | + expiresAt: result.expiresAt, | |
| 146 | + createdAt: result.createdAt, | |
| 147 | + }; | |
| 148 | + } | |
| 149 | + | |
| 150 | + async purgeExpiredAuthorizationCodes(now: number): Promise<number> { | |
| 151 | + const result = await this.convex.mutation(api.anonymousOauth.purgeExpiredAuthorizationCodes, { | |
| 152 | + internalSecret: this.internalSecret, | |
| 153 | + now, | |
| 154 | + }); | |
| 155 | + return result.purged; | |
| 156 | + } | |
| 157 | + | |
| 158 | + async countAuthorizationCodes(): Promise<number> { | |
| 159 | + const result = await this.convex.query(api.anonymousOauth.countAuthorizationCodes, { | |
| 160 | + internalSecret: this.internalSecret, | |
| 161 | + }); | |
| 162 | + return result.count; | |
| 163 | + } | |
| 100 | 164 | } |
| @@ -1,13 +1,13 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { resolveCredentialPayload } from "./credential-providers"; |
| 3 | 3 | |
| 4 | −test("managed provider returns stored payload", async () => { | |
| 4 | +test("local provider returns stored payload", async () => { | |
| 5 | 5 | const payload = await resolveCredentialPayload({ |
| 6 | − provider: "managed", | |
| 7 | − secretJson: { token: "managed-token" }, | |
| 6 | + provider: "local-convex", | |
| 7 | + secretJson: { token: "local-token" }, | |
| 8 | 8 | }); |
| 9 | 9 | |
| 10 | − expect(payload).toEqual({ token: "managed-token" }); | |
| 10 | + expect(payload).toEqual({ token: "local-token" }); | |
| 11 | 11 | }); |
| 12 | 12 | |
| 13 | 13 | test("WorkOS Vault provider reads object and parses JSON", async () => { |
| @@ -105,7 +105,7 @@ function requireString(config: Record<string, unknown>, key: string, providerNam · 104 unmodified lines | ||
| 105 | 105 | return value.trim(); |
| 106 | 106 | } |
| 107 | 107 | |
| 108 | −async function resolveManaged( | |
| 108 | +async function resolveLocalConvex( | |
| 109 | 109 | record: Pick<CredentialRecord, "secretJson">, |
| 110 | 110 | ): Promise<CredentialPayload | null> { |
| 111 | 111 | return asRecord(record.secretJson); |
| @@ -135,7 +135,7 @@ async function resolveWorkosVault( · 23 unmodified lines | ||
| 135 | 135 | } |
| 136 | 136 | |
| 137 | 137 | const providers: Record<CredentialProvider, ProviderResolver> = { |
| 138 | − managed: resolveManaged, | |
| 138 | + "local-convex": resolveLocalConvex, | |
| 139 | 139 | "workos-vault": resolveWorkosVault, |
| 140 | 140 | }; |
| 141 | 141 | |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | −import { clearDtsUrlCache, loadSourceDtsByUrlCached } from "./dts-loader"; | |
| 2 | +import { loadSourceDtsByUrl } from "./dts-loader"; | |
| 3 | 3 | |
| 4 | −test("loadSourceDtsByUrlCached reuses cached URL fetches", async () => { | |
| 4 | +test("loadSourceDtsByUrl fetches declarations for each source key", async () => { | |
| 5 | 5 | let requestCount = 0; |
| 6 | 6 | const server = Bun.serve({ |
| 7 | 7 | port: 0, |
| @@ -14,50 +14,51 @@ test("loadSourceDtsByUrlCached reuses cached URL fetches", async () => { · 6 unmodified lines | ||
| 14 | 14 | }); |
| 15 | 15 | |
| 16 | 16 | try { |
| 17 | − clearDtsUrlCache(); | |
| 18 | 17 | const url = `http://127.0.0.1:${server.port}/types.d.ts`; |
| 19 | 18 | |
| 20 | − const first = await loadSourceDtsByUrlCached({ | |
| 19 | + const first = await loadSourceDtsByUrl({ | |
| 21 | 20 | "openapi:one": url, |
| 22 | 21 | "openapi:two": url, |
| 23 | 22 | }); |
| 24 | − const second = await loadSourceDtsByUrlCached({ | |
| 23 | + const second = await loadSourceDtsByUrl({ | |
| 25 | 24 | "openapi:one": url, |
| 26 | 25 | }); |
| 27 | 26 | |
| 28 | 27 | expect(first["openapi:one"]).toContain("interface operations"); |
| 29 | 28 | expect(first["openapi:two"]).toContain("interface operations"); |
| 30 | 29 | expect(second["openapi:one"]).toContain("interface operations"); |
| 31 | − expect(requestCount).toBe(1); | |
| 30 | + expect(requestCount).toBe(3); | |
| 32 | 31 | } finally { |
| 33 | 32 | server.stop(true); |
| 34 | − clearDtsUrlCache(); | |
| 35 | 33 | } |
| 36 | 34 | }); |
| 37 | 35 | |
| 38 | −test("loadSourceDtsByUrlCached respects ttl expiration", async () => { | |
| 39 | − let requestCount = 0; | |
| 36 | +test("loadSourceDtsByUrl skips sources that fail to download", async () => { | |
| 40 | 37 | const server = Bun.serve({ |
| 41 | 38 | port: 0, |
| 42 | − fetch: () => { | |
| 43 | − requestCount += 1; | |
| 44 | − return new Response("export interface operations { ping: {} }", { | |
| 39 | + fetch: (request) => { | |
| 40 | + const url = new URL(request.url); | |
| 41 | + if (url.pathname === "/fail.d.ts") { | |
| 42 | + return new Response("nope", { status: 500 }); | |
| 43 | + } | |
| 44 | + return new Response("export interface operations { pong: {} }", { | |
| 45 | 45 | headers: { "content-type": "text/plain" }, |
| 46 | 46 | }); |
| 47 | 47 | }, |
| 48 | 48 | }); |
| 49 | 49 | |
| 50 | 50 | try { |
| 51 | − clearDtsUrlCache(); | |
| 52 | − const url = `http://127.0.0.1:${server.port}/types.d.ts`; | |
| 51 | + const okUrl = `http://127.0.0.1:${server.port}/ok.d.ts`; | |
| 52 | + const failUrl = `http://127.0.0.1:${server.port}/fail.d.ts`; | |
| 53 | 53 | |
| 54 | − await loadSourceDtsByUrlCached({ "openapi:one": url }, { ttlMs: 5 }); | |
| 55 | − await Bun.sleep(20); | |
| 56 | − await loadSourceDtsByUrlCached({ "openapi:one": url }, { ttlMs: 5 }); | |
| 54 | + const loaded = await loadSourceDtsByUrl({ | |
| 55 | + "openapi:ok": okUrl, | |
| 56 | + "openapi:fail": failUrl, | |
| 57 | + }); | |
| 57 | 58 | |
| 58 | − expect(requestCount).toBe(2); | |
| 59 | + expect(loaded["openapi:ok"]).toContain("interface operations"); | |
| 60 | + expect(loaded["openapi:fail"]).toBeUndefined(); | |
| 59 | 61 | } finally { |
| 60 | 62 | server.stop(true); |
| 61 | − clearDtsUrlCache(); | |
| 62 | 63 | } |
| 63 | 64 | }); |
| @@ -1,12 +1,3 @@ | ||
| 1 | −const DEFAULT_DTS_CACHE_TTL_MS = 5 * 60_000; | |
| 2 | − | |
| 3 | −interface DtsCacheEntry { | |
| 4 | − expiresAt: number; | |
| 5 | − promise: Promise<string | null>; | |
| 6 | −} | |
| 7 | − | |
| 8 | −const dtsUrlCache = new Map<string, DtsCacheEntry>(); | |
| 9 | − | |
| 10 | 1 | async function fetchDtsText(url: string): Promise<string | null> { |
| 11 | 2 | try { |
| 12 | 3 | const response = await fetch(url); |
| @@ -23,34 +14,17 @@ async function fetchDtsText(url: string): Promise<string | null> { · 10 unmodified lines | ||
| 23 | 14 | } |
| 24 | 15 | } |
| 25 | 16 | |
| 26 | −function getCachedDtsPromise(url: string, ttlMs: number): Promise<string | null> { | |
| 27 | − const now = Date.now(); | |
| 28 | − const cached = dtsUrlCache.get(url); | |
| 29 | − if (cached && cached.expiresAt > now) { | |
| 30 | − return cached.promise; | |
| 31 | − } | |
| 32 | − | |
| 33 | − const promise = fetchDtsText(url).catch(() => null); | |
| 34 | − dtsUrlCache.set(url, { | |
| 35 | − expiresAt: now + Math.max(1, ttlMs), | |
| 36 | − promise, | |
| 37 | − }); | |
| 38 | − return promise; | |
| 39 | −} | |
| 40 | − | |
| 41 | −export async function loadSourceDtsByUrlCached( | |
| 17 | +export async function loadSourceDtsByUrl( | |
| 42 | 18 | dtsUrls: Record<string, string>, |
| 43 | − options?: { ttlMs?: number }, | |
| 44 | 19 | ): Promise<Record<string, string>> { |
| 45 | 20 | const entries = Object.entries(dtsUrls); |
| 46 | 21 | if (entries.length === 0) { |
| 47 | 22 | return {}; |
| 48 | 23 | } |
| 49 | 24 | |
| 50 | − const ttlMs = options?.ttlMs ?? DEFAULT_DTS_CACHE_TTL_MS; | |
| 51 | 25 | const results = await Promise.all(entries.map(async ([sourceKey, url]) => { |
| 52 | 26 | if (!url) return [sourceKey, null] as const; |
| 53 | − return [sourceKey, await getCachedDtsPromise(url, ttlMs)] as const; | |
| 27 | + return [sourceKey, await fetchDtsText(url)] as const; | |
| 54 | 28 | })); |
| 55 | 29 | |
| 56 | 30 | const sourceDtsBySource: Record<string, string> = {}; |
| @@ -61,7 +35,3 @@ export async function loadSourceDtsByUrlCached( · 4 unmodified lines | ||
| 61 | 35 | } |
| 62 | 36 | return sourceDtsBySource; |
| 63 | 37 | } |
| 64 | − | |
| 65 | −export function clearDtsUrlCache(): void { | |
| 66 | − dtsUrlCache.clear(); | |
| 67 | −} | |
| @@ -24,12 +24,12 @@ export type WorkspaceAccess = { · 23 unmodified lines | ||
| 24 | 24 | workspaceMembership: WorkspaceAccessMembership; |
| 25 | 25 | }; |
| 26 | 26 | |
| 27 | −export function slugify(input: string): string { | |
| 27 | +export function slugify(input: string, fallback = "team"): string { | |
| 28 | 28 | const slug = input |
| 29 | 29 | .toLowerCase() |
| 30 | 30 | .replace(/[^a-z0-9]+/g, "-") |
| 31 | 31 | .replace(/^-+|-+$/g, ""); |
| 32 | − return slug.length > 0 ? slug : "team"; | |
| 32 | + return slug.length > 0 ? slug : fallback; | |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | 35 | export async function resolveAccountForRequest( |
| @@ -4,7 +4,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ · 3 unmodified lines | ||
| 4 | 4 | import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 5 | 5 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 6 | 6 | import type { OAuthClientMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; |
| 7 | −import { AnonymousOAuthServer, OAuthBadRequest } from "./anonymous-oauth"; | |
| 7 | +import { AnonymousOAuthServer, InMemoryOAuthStorage, OAuthBadRequest } from "./anonymous-oauth"; | |
| 8 | 8 | import { handleMcpRequest, type McpWorkspaceContext } from "./mcp-server"; |
| 9 | 9 | import type { AnonymousContext, CreateTaskInput, TaskRecord, ToolDescriptor } from "./types"; |
| 10 | 10 | import type { LiveTaskEvent } from "./events"; |
| @@ -55,8 +55,7 @@ class FakeMcpService { · 44 unmodified lines | ||
| 55 | 55 | completedAt: current.createdAt + 2, |
| 56 | 56 | updatedAt: current.createdAt + 2, |
| 57 | 57 | exitCode: 0, |
| 58 | − stdout: `ran:${input.code.slice(0, 20)}`, | |
| 59 | − stderr: "", | |
| 58 | + result: { ran: input.code.slice(0, 20) }, | |
| 60 | 59 | }); |
| 61 | 60 | for (const listener of this.listeners.get(id) ?? []) { |
| 62 | 61 | listener({ id: 1, eventName: "task", payload: { status: "completed" }, createdAt: Date.now() }); |
| @@ -297,7 +296,10 @@ beforeAll(async () => { · 234 unmodified lines | ||
| 297 | 296 | }); |
| 298 | 297 | |
| 299 | 298 | baseUrl = `http://127.0.0.1:${server.port}`; |
| 300 | − oauthServer = new AnonymousOAuthServer({ issuer: baseUrl }); | |
| 299 | + oauthServer = new AnonymousOAuthServer({ | |
| 300 | + issuer: baseUrl, | |
| 301 | + storage: new InMemoryOAuthStorage(), | |
| 302 | + }); | |
| 301 | 303 | await oauthServer.init(); |
| 302 | 304 | }); |
| 303 | 305 | |
| @@ -19,7 +19,7 @@ import { computeS256Challenge } from "./anonymous-oauth"; · 18 unmodified lines | ||
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | 21 | // We import the gateway init helpers directly to avoid needing CONVEX_URL |
| 22 | −import { AnonymousOAuthServer, OAuthBadRequest } from "./anonymous-oauth"; | |
| 22 | +import { AnonymousOAuthServer, InMemoryOAuthStorage, OAuthBadRequest } from "./anonymous-oauth"; | |
| 23 | 23 | |
| 24 | 24 | let server: ReturnType<typeof Bun.serve>; |
| 25 | 25 | let baseUrl: string; |
| @@ -44,7 +44,10 @@ function generateCodeVerifier(): string { · 18 unmodified lines | ||
| 44 | 44 | beforeAll(async () => { |
| 45 | 45 | // We start our own server so we don't need CONVEX_URL. |
| 46 | 46 | const port = 0; // random available port |
| 47 | − oauthServer = new AnonymousOAuthServer({ issuer: "http://placeholder" }); | |
| 47 | + oauthServer = new AnonymousOAuthServer({ | |
| 48 | + issuer: "http://placeholder", | |
| 49 | + storage: new InMemoryOAuthStorage(), | |
| 50 | + }); | |
| 48 | 51 | await oauthServer.init(); |
| 49 | 52 | |
| 50 | 53 | server = Bun.serve({ |
| @@ -172,7 +175,10 @@ beforeAll(async () => { · 121 unmodified lines | ||
| 172 | 175 | |
| 173 | 176 | // Update the oauth server's issuer to match the real URL |
| 174 | 177 | // We do this by re-initializing with the correct issuer |
| 175 | − oauthServer = new AnonymousOAuthServer({ issuer: baseUrl }); | |
| 178 | + oauthServer = new AnonymousOAuthServer({ | |
| 179 | + issuer: baseUrl, | |
| 180 | + storage: new InMemoryOAuthStorage(), | |
| 181 | + }); | |
| 176 | 182 | await oauthServer.init(); |
| 177 | 183 | }); |
| 178 | 184 | |
| @@ -44,10 +44,9 @@ class FakeMcpService { · 43 unmodified lines | ||
| 44 | 44 | completedAt: current.createdAt + 2, |
| 45 | 45 | updatedAt: current.createdAt + 2, |
| 46 | 46 | exitCode: 0, |
| 47 | − stdout: input.metadata?.largeStdout === true | |
| 48 | − ? `header\n${"x".repeat(35_000)}` | |
| 49 | − : `ran:${input.code.slice(0, 20)}`, | |
| 50 | − stderr: "", | |
| 47 | + result: input.metadata?.largeResult === true | |
| 48 | + ? { data: `header\n${"x".repeat(35_000)}` } | |
| 49 | + : { ran: input.code.slice(0, 20) }, | |
| 51 | 50 | }); |
| 52 | 51 | // Notify subscribers |
| 53 | 52 | for (const listener of this.listeners.get(id) ?? []) { |
| @@ -164,12 +163,12 @@ test("run_code MCP tool returns terminal task result", async () => { · 110 unmodified lines | ||
| 164 | 163 | expect(text?.type).toBe("text"); |
| 165 | 164 | if (text?.type === "text") { |
| 166 | 165 | expect(text.text).toContain("status: completed"); |
| 167 | − expect(text.text).toContain("ran:console.log('hello f"); | |
| 166 | + expect(text.text).toContain('"ran": "console.log'); | |
| 168 | 167 | } |
| 169 | 168 | }); |
| 170 | 169 | }); |
| 171 | 170 | |
| 172 | −test("run_code MCP tool warns and previews large output", async () => { | |
| 171 | +test("run_code MCP tool previews large returned result", async () => { | |
| 173 | 172 | const service = new FakeMcpService(); |
| 174 | 173 | |
| 175 | 174 | await withMcpClient(service, async (client) => { |
| @@ -177,7 +176,7 @@ test("run_code MCP tool warns and previews large output", async () => { · 1 unmodified line | ||
| 177 | 176 | name: "run_code", |
| 178 | 177 | arguments: { |
| 179 | 178 | code: "console.log('large')", |
| 180 | − metadata: { largeStdout: true }, | |
| 179 | + metadata: { largeResult: true }, | |
| 181 | 180 | }, |
| 182 | 181 | })) as { |
| 183 | 182 | isError?: boolean; |
| @@ -189,15 +188,11 @@ test("run_code MCP tool warns and previews large output", async () => { · 5 unmodified lines | ||
| 189 | 188 | const text = result.content.find((part) => part.type === "text"); |
| 190 | 189 | expect(text?.type).toBe("text"); |
| 191 | 190 | if (text?.type === "text") { |
| 192 | − expect(text.text).toContain("outputWarning: Large runtime output detected"); | |
| 193 | − expect(text.text).toContain("outputHint: Return compact summaries"); | |
| 194 | − expect(text.text).toContain("[stdout preview truncated"); | |
| 191 | + expect(text.text).toContain("[result preview truncated"); | |
| 195 | 192 | } |
| 196 | 193 | |
| 197 | − const fullStdout = typeof result.structuredContent?.stdout === "string" | |
| 198 | − ? result.structuredContent.stdout | |
| 199 | − : ""; | |
| 200 | − expect(fullStdout.length).toBeGreaterThan(30_000); | |
| 194 | + const fullResult = result.structuredContent?.result as { data?: string } | undefined; | |
| 195 | + expect((fullResult?.data ?? "").length).toBeGreaterThan(30_000); | |
| 201 | 196 | }); |
| 202 | 197 | }); |
| 203 | 198 | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 2 | 2 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| 3 | +import { Result } from "better-result"; | |
| 3 | 4 | import { z } from "zod"; |
| 4 | −import { loadSourceDtsByUrlCached } from "./dts-loader"; | |
| 5 | +import { loadSourceDtsByUrl } from "./dts-loader"; | |
| 5 | 6 | import { generateToolDeclarations, generateToolInventory, typecheckCode } from "./typechecker"; |
| 6 | 7 | import type { LiveTaskEvent } from "./events"; |
| 7 | 8 | import type { |
| @@ -94,7 +95,7 @@ function listTopLevelToolKeys(tools: ToolDescriptor[]): string[] { · 86 unmodified lines | ||
| 94 | 95 | } |
| 95 | 96 | |
| 96 | 97 | function summarizeTask(task: TaskRecord): string { |
| 97 | − const maxStreamPreviewChars = 30_000; | |
| 98 | + const maxResultPreviewChars = 30_000; | |
| 98 | 99 | const lines = [ |
| 99 | 100 | `taskId: ${task.id}`, |
| 100 | 101 | `status: ${task.status}`, |
| @@ -109,29 +110,17 @@ function summarizeTask(task: TaskRecord): string { · 8 unmodified lines | ||
| 109 | 110 | lines.push(`error: ${task.error}`); |
| 110 | 111 | } |
| 111 | 112 | |
| 112 | − const stdoutLength = task.stdout?.length ?? 0; | |
| 113 | − const stderrLength = task.stderr?.length ?? 0; | |
| 114 | − if (stdoutLength > maxStreamPreviewChars || stderrLength > maxStreamPreviewChars) { | |
| 115 | − lines.push( | |
| 116 | − `outputWarning: Large runtime output detected (stdout=${stdoutLength} chars, stderr=${stderrLength} chars).`, | |
| 117 | − ); | |
| 118 | − lines.push( | |
| 119 | − "outputHint: Return compact summaries (counts, IDs, top-N samples) to avoid truncation in tool responses.", | |
| 120 | − ); | |
| 121 | − } | |
| 122 | − | |
| 123 | 113 | let text = lines.join("\n"); |
| 124 | − if (task.stdout && task.stdout.trim()) { | |
| 125 | − const stdoutPreview = task.stdout.length > maxStreamPreviewChars | |
| 126 | − ? `${task.stdout.slice(0, maxStreamPreviewChars)}\n... [stdout preview truncated ${task.stdout.length - maxStreamPreviewChars} chars]` | |
| 127 | − : task.stdout; | |
| 128 | − text += asCodeBlock("text", stdoutPreview); | |
| 129 | − } | |
| 130 | − if (task.stderr && task.stderr.trim()) { | |
| 131 | − const stderrPreview = task.stderr.length > maxStreamPreviewChars | |
| 132 | − ? `${task.stderr.slice(0, maxStreamPreviewChars)}\n... [stderr preview truncated ${task.stderr.length - maxStreamPreviewChars} chars]` | |
| 133 | − : task.stderr; | |
| 134 | − text += asCodeBlock("text", stderrPreview); | |
| 114 | + if (task.result !== undefined) { | |
| 115 | + const serialized = Result.try(() => JSON.stringify(task.result, null, 2)).unwrapOr(String(task.result)); | |
| 116 | + if (serialized.length > maxResultPreviewChars) { | |
| 117 | + text += asCodeBlock( | |
| 118 | + "json", | |
| 119 | + `${serialized.slice(0, maxResultPreviewChars)}\n... [result preview truncated ${serialized.length - maxResultPreviewChars} chars]`, | |
| 120 | + ); | |
| 121 | + } else { | |
| 122 | + text += asCodeBlock("json", serialized); | |
| 123 | + } | |
| 135 | 124 | } |
| 136 | 125 | return text; |
| 137 | 126 | } |
| @@ -260,7 +249,7 @@ function waitForTerminalTask( · 122 unmodified lines | ||
| 260 | 249 | |
| 261 | 250 | function buildRunCodeDescription(tools?: ToolDescriptor[]): string { |
| 262 | 251 | const base = |
| 263 | − "Execute TypeScript code in a sandboxed runtime. The code has access to a `tools` object with typed methods for calling external services. Use `return` to return a value. Waits for completion and returns stdout/stderr. Code is typechecked before execution — type errors are returned without running. Runtime has no filesystem/process/import access; use `tools.*` for external calls."; | |
| 252 | + "Execute TypeScript code in a sandboxed runtime. The code has access to a `tools` object with typed methods for calling external services. Use `return` to return a value. Waits for completion and returns only explicit return values (console output is not returned). Code is typechecked before execution — type errors are returned without running. Runtime has no filesystem/process/import access; use `tools.*` for external calls."; | |
| 264 | 253 | const toolList = tools ?? []; |
| 265 | 254 | const topLevelKeys = listTopLevelToolKeys(toolList); |
| 266 | 255 | const rootKeysNote = topLevelKeys.length > 0 |
| @@ -428,7 +417,7 @@ function createRunCodeTool( · 161 unmodified lines | ||
| 428 | 417 | clientId: context.clientId, |
| 429 | 418 | }); |
| 430 | 419 | toolsForContext = tools; |
| 431 | − sourceDtsBySource = await loadSourceDtsByUrlCached(dtsUrls ?? {}); | |
| 420 | + sourceDtsBySource = await loadSourceDtsByUrl(dtsUrls ?? {}); | |
| 432 | 421 | } else { |
| 433 | 422 | toolsForContext = await service.listTools({ |
| 434 | 423 | workspaceId: context.workspaceId, |
| @@ -537,8 +526,7 @@ function createRunCodeTool( · 102 unmodified lines | ||
| 537 | 526 | runtimeId: task.runtimeId, |
| 538 | 527 | exitCode: task.exitCode, |
| 539 | 528 | error: task.error, |
| 540 | − stdout: task.stdout, | |
| 541 | − stderr: task.stderr, | |
| 529 | + result: task.result, | |
| 542 | 530 | workspaceId: context.workspaceId, |
| 543 | 531 | actorId: context.actorId, |
| 544 | 532 | sessionId: context.sessionId, |
| @@ -606,36 +594,6 @@ async function createMcpServer( · 61 unmodified lines | ||
| 606 | 594 | return mcp; |
| 607 | 595 | } |
| 608 | 596 | |
| 609 | −function createDelegatingService(ref: { current: McpExecutorService }): McpExecutorService { | |
| 610 | − return { | |
| 611 | − createTask: async (input) => await ref.current.createTask(input), | |
| 612 | − getTask: async (taskId, workspaceId) => await ref.current.getTask(taskId, workspaceId), | |
| 613 | − subscribe: (taskId, listener) => ref.current.subscribe(taskId, listener), | |
| 614 | − bootstrapAnonymousContext: async (sessionId) => await ref.current.bootstrapAnonymousContext(sessionId), | |
| 615 | − listTools: async (context) => await ref.current.listTools(context), | |
| 616 | − listToolsForTypecheck: ref.current.listToolsForTypecheck | |
| 617 | − ? async (context) => await ref.current.listToolsForTypecheck!(context) | |
| 618 | − : undefined, | |
| 619 | − typecheckRunCode: ref.current.typecheckRunCode | |
| 620 | − ? async (input) => await ref.current.typecheckRunCode!(input) | |
| 621 | − : undefined, | |
| 622 | − listPendingApprovals: ref.current.listPendingApprovals | |
| 623 | − ? async (workspaceId) => await ref.current.listPendingApprovals!(workspaceId) | |
| 624 | − : undefined, | |
| 625 | − resolveApproval: ref.current.resolveApproval | |
| 626 | − ? async (input) => await ref.current.resolveApproval!(input) | |
| 627 | − : undefined, | |
| 628 | − }; | |
| 629 | −} | |
| 630 | − | |
| 631 | −type McpSessionRuntime = { | |
| 632 | − transport: WebStandardStreamableHTTPServerTransport; | |
| 633 | − mcp: McpServer; | |
| 634 | − serviceRef: { current: McpExecutorService }; | |
| 635 | −}; | |
| 636 | − | |
| 637 | −const mcpSessionRuntimes: Record<string, McpSessionRuntime> = Object.create(null); | |
| 638 | − | |
| 639 | 597 | async function handleStatelessMcpRequest( |
| 640 | 598 | service: McpExecutorService, |
| 641 | 599 | request: Request, |
| @@ -665,64 +623,8 @@ export async function handleMcpRequest( · 23 unmodified lines | ||
| 665 | 623 | request: Request, |
| 666 | 624 | context?: McpWorkspaceContext, |
| 667 | 625 | ): Promise<Response> { |
| 668 | − const runtimeSessionId = request.headers.get("mcp-session-id") ?? undefined; | |
| 669 | − | |
| 670 | − if (runtimeSessionId) { | |
| 671 | − const runtime = mcpSessionRuntimes[runtimeSessionId]; | |
| 672 | − if (runtime) { | |
| 673 | − runtime.serviceRef.current = service; | |
| 674 | − return await runtime.transport.handleRequest(request); | |
| 675 | − } | |
| 676 | − | |
| 677 | − const headers = new Headers(request.headers); | |
| 678 | − headers.delete("mcp-session-id"); | |
| 679 | − const requestWithoutSession = new Request(request, { headers }); | |
| 680 | − return await handleStatelessMcpRequest(service, requestWithoutSession, context); | |
| 681 | − } | |
| 682 | − | |
| 683 | − const serviceRef = { current: service }; | |
| 684 | − const delegatingService = createDelegatingService(serviceRef); | |
| 685 | − const mcp = await createMcpServer(delegatingService, context); | |
| 686 | − | |
| 687 | − const transport = new WebStandardStreamableHTTPServerTransport({ | |
| 688 | − sessionIdGenerator: () => crypto.randomUUID(), | |
| 689 | − enableJsonResponse: true, | |
| 690 | − onsessioninitialized: (sessionId) => { | |
| 691 | − mcpSessionRuntimes[sessionId] = { | |
| 692 | − transport, | |
| 693 | − mcp, | |
| 694 | − serviceRef, | |
| 695 | − }; | |
| 696 | − }, | |
| 697 | − onsessionclosed: async (sessionId) => { | |
| 698 | − if (!sessionId) { | |
| 699 | − return; | |
| 700 | − } | |
| 701 | − | |
| 702 | − const runtime = mcpSessionRuntimes[sessionId]; | |
| 703 | − if (!runtime) { | |
| 704 | − return; | |
| 705 | − } | |
| 706 | − | |
| 707 | − delete mcpSessionRuntimes[sessionId]; | |
| 708 | − await runtime.mcp.close().catch(() => {}); | |
| 709 | − }, | |
| 710 | − }); | |
| 711 | − | |
| 712 | − try { | |
| 713 | − await mcp.connect(transport); | |
| 714 | − const response = await transport.handleRequest(request); | |
| 715 | − | |
| 716 | − const initializedSessionId = response.headers.get("mcp-session-id") ?? undefined; | |
| 717 | − if (!initializedSessionId) { | |
| 718 | − await transport.close().catch(() => {}); | |
| 719 | − await mcp.close().catch(() => {}); | |
| 720 | − } | |
| 721 | − | |
| 722 | − return response; | |
| 723 | − } catch (error) { | |
| 724 | − await transport.close().catch(() => {}); | |
| 725 | − await mcp.close().catch(() => {}); | |
| 726 | − throw error; | |
| 727 | − } | |
| 626 | + const headers = new Headers(request.headers); | |
| 627 | + headers.delete("mcp-session-id"); | |
| 628 | + const requestWithoutSession = new Request(request, { headers }); | |
| 629 | + return await handleStatelessMcpRequest(service, requestWithoutSession, context); | |
| 728 | 630 | } |
| @@ -345,6 +345,99 @@ describe("buildOpenApiToolsFromPrepared", () => { · 344 unmodified lines | ||
| 345 | 345 | ); |
| 346 | 346 | }); |
| 347 | 347 | |
| 348 | + test("includes anyOf body keys in preview args and input hint", async () => { | |
| 349 | + const spec: Record<string, unknown> = { | |
| 350 | + openapi: "3.0.3", | |
| 351 | + info: { title: "DNS", version: "1.0.0" }, | |
| 352 | + servers: [{ url: "https://api.example.com" }], | |
| 353 | + paths: { | |
| 354 | + "/domains/{domain}/records": { | |
| 355 | + post: { | |
| 356 | + operationId: "createRecord", | |
| 357 | + tags: ["dns"], | |
| 358 | + summary: "Create record", | |
| 359 | + parameters: [ | |
| 360 | + { | |
| 361 | + name: "domain", | |
| 362 | + in: "path", | |
| 363 | + required: true, | |
| 364 | + schema: { type: "string" }, | |
| 365 | + }, | |
| 366 | + ], | |
| 367 | + requestBody: { | |
| 368 | + required: true, | |
| 369 | + content: { | |
| 370 | + "application/json": { | |
| 371 | + schema: { | |
| 372 | + required: ["type"], | |
| 373 | + properties: { | |
| 374 | + type: { | |
| 375 | + type: "string", | |
| 376 | + enum: ["A", "CNAME"], | |
| 377 | + }, | |
| 378 | + }, | |
| 379 | + anyOf: [ | |
| 380 | + { | |
| 381 | + type: "object", | |
| 382 | + required: ["type", "name", "value"], | |
| 383 | + properties: { | |
| 384 | + name: { type: "string" }, | |
| 385 | + type: { type: "string", enum: ["A"] }, | |
| 386 | + value: { type: "string" }, | |
| 387 | + }, | |
| 388 | + }, | |
| 389 | + { | |
| 390 | + type: "object", | |
| 391 | + required: ["type", "name", "value"], | |
| 392 | + properties: { | |
| 393 | + name: { type: "string" }, | |
| 394 | + type: { type: "string", enum: ["CNAME"] }, | |
| 395 | + value: { type: "string" }, | |
| 396 | + }, | |
| 397 | + }, | |
| 398 | + ], | |
| 399 | + }, | |
| 400 | + }, | |
| 401 | + }, | |
| 402 | + }, | |
| 403 | + responses: { | |
| 404 | + "200": { | |
| 405 | + description: "ok", | |
| 406 | + content: { | |
| 407 | + "application/json": { | |
| 408 | + schema: { | |
| 409 | + type: "object", | |
| 410 | + properties: { id: { type: "string" } }, | |
| 411 | + }, | |
| 412 | + }, | |
| 413 | + }, | |
| 414 | + }, | |
| 415 | + }, | |
| 416 | + }, | |
| 417 | + }, | |
| 418 | + }, | |
| 419 | + }; | |
| 420 | + | |
| 421 | + const prepared = await prepareOpenApiSpec(spec, "dns"); | |
| 422 | + const tools = buildOpenApiToolsFromPrepared( | |
| 423 | + { | |
| 424 | + type: "openapi", | |
| 425 | + name: "vercel", | |
| 426 | + spec, | |
| 427 | + baseUrl: "https://api.example.com", | |
| 428 | + }, | |
| 429 | + prepared, | |
| 430 | + ); | |
| 431 | + | |
| 432 | + const tool = tools.find((candidate) => candidate.metadata?.operationId === "createRecord"); | |
| 433 | + expect(tool).toBeDefined(); | |
| 434 | + expect(tool?.metadata?.argPreviewKeys).toEqual(["domain", "type", "name", "value"]); | |
| 435 | + expect(tool?.metadata?.displayArgsType).toContain("name: ..."); | |
| 436 | + expect(tool?.metadata?.displayArgsType).toContain("value: ..."); | |
| 437 | + expect(tool?.metadata?.argsType).toContain("name"); | |
| 438 | + expect(tool?.metadata?.argsType).toContain("value"); | |
| 439 | + }); | |
| 440 | + | |
| 348 | 441 | test("infers workspace auth from OpenAPI securitySchemes when source auth is unset", async () => { |
| 349 | 442 | const spec: Record<string, unknown> = { |
| 350 | 443 | openapi: "3.0.3", |
| @@ -21,8 +21,8 @@ import { transpileForRuntime } from "./transpile"; · 20 unmodified lines | ||
| 21 | 21 | * `ToolBridge` entrypoint in the host Worker (passed via `env` bindings), |
| 22 | 22 | * which in turn calls Convex callback RPCs to resolve tools. |
| 23 | 23 | * |
| 24 | − * 4. Console output from the isolate is similarly relayed back via callback | |
| 25 | − * RPC mutation. | |
| 24 | + * 4. Only explicit `return` values from the isolate are included in callback | |
| 25 | + * completion payloads. | |
| 26 | 26 | * |
| 27 | 27 | * 5. The host Worker accepts the run immediately and finishes execution |
| 28 | 28 | * asynchronously, reporting terminal results back through callback RPC. |
| @@ -4,7 +4,6 @@ import { loadExternalTools } from "../tool-sources"; · 3 unmodified lines | ||
| 4 | 4 | import type { Id } from "../../../convex/_generated/dataModel"; |
| 5 | 5 | import type { |
| 6 | 6 | ExecutionAdapter, |
| 7 | − RuntimeOutputEvent, | |
| 8 | 7 | SandboxExecutionRequest, |
| 9 | 8 | ToolDefinition, |
| 10 | 9 | ToolRunContext, |
| @@ -20,7 +19,6 @@ function request(code: string, timeoutMs = 1_000): SandboxExecutionRequest { · 9 unmodified lines | ||
| 20 | 19 | |
| 21 | 20 | function createRuntimeAdapter( |
| 22 | 21 | tools: Map<string, ToolDefinition>, |
| 23 | − outputEvents: RuntimeOutputEvent[], | |
| 24 | 22 | ): ExecutionAdapter { |
| 25 | 23 | return { |
| 26 | 24 | async invokeTool(call) { |
| @@ -51,14 +49,10 @@ function createRuntimeAdapter( · 24 unmodified lines | ||
| 51 | 49 | }; |
| 52 | 50 | } |
| 53 | 51 | }, |
| 54 | − emitOutput(event) { | |
| 55 | − outputEvents.push(event); | |
| 56 | − }, | |
| 57 | 52 | }; |
| 58 | 53 | } |
| 59 | 54 | |
| 60 | −test("executes tool calls and captures output", async () => { | |
| 61 | − const outputEvents: RuntimeOutputEvent[] = []; | |
| 55 | +test("executes tool calls and captures returned result", async () => { | |
| 62 | 56 | const tools = new Map<string, ToolDefinition>([ |
| 63 | 57 | [ |
| 64 | 58 | "utils.echo", |
| @@ -74,16 +68,13 @@ test("executes tool calls and captures output", async () => { · 9 unmodified lines | ||
| 74 | 68 | const result = await runCodeWithAdapter( |
| 75 | 69 | request(` |
| 76 | 70 | const out = await tools.utils.echo({ message: "hi" }); |
| 77 | − console.log("out", out.echoed.message); | |
| 78 | − return 42; | |
| 71 | + return { message: out.echoed.message, total: 42 }; | |
| 79 | 72 | `), |
| 80 | − createRuntimeAdapter(tools, outputEvents), | |
| 73 | + createRuntimeAdapter(tools), | |
| 81 | 74 | ); |
| 82 | 75 | |
| 83 | 76 | expect(result.status).toBe("completed"); |
| 84 | − expect(result.stdout).toContain("out hi"); | |
| 85 | − expect(result.stdout).toContain("result: 42"); | |
| 86 | − expect(outputEvents.some((event) => event.stream === "stdout")).toBe(true); | |
| 77 | + expect(result.result).toEqual({ message: "hi", total: 42 }); | |
| 87 | 78 | }); |
| 88 | 79 | |
| 89 | 80 | test("returns denied when adapter marks tool call denied", async () => { |
| @@ -95,7 +86,6 @@ test("returns denied when adapter marks tool call denied", async () => { · 5 unmodified lines | ||
| 95 | 86 | error: "policy denied", |
| 96 | 87 | }; |
| 97 | 88 | }, |
| 98 | − emitOutput() {}, | |
| 99 | 89 | }; |
| 100 | 90 | |
| 101 | 91 | const result = await runCodeWithAdapter( |
| @@ -107,7 +97,6 @@ test("returns denied when adapter marks tool call denied", async () => { · 5 unmodified lines | ||
| 107 | 97 | |
| 108 | 98 | expect(result.status).toBe("denied"); |
| 109 | 99 | expect(result.error).toBe("policy denied"); |
| 110 | − expect(result.stderr).toContain("policy denied"); | |
| 111 | 100 | }); |
| 112 | 101 | |
| 113 | 102 | test("times out long-running code", async () => { |
| @@ -115,7 +104,6 @@ test("times out long-running code", async () => { · 1 unmodified line | ||
| 115 | 104 | async invokeTool() { |
| 116 | 105 | return { ok: true, value: null }; |
| 117 | 106 | }, |
| 118 | − emitOutput() {}, | |
| 119 | 107 | }; |
| 120 | 108 | |
| 121 | 109 | const result = await runCodeWithAdapter( |
| @@ -133,8 +121,7 @@ test("times out long-running code", async () => { · 11 unmodified lines | ||
| 133 | 121 | }); |
| 134 | 122 | |
| 135 | 123 | test("sandboxed runner does not expose host globals", async () => { |
| 136 | − const outputEvents: RuntimeOutputEvent[] = []; | |
| 137 | − const adapter = createRuntimeAdapter(new Map(), outputEvents); | |
| 124 | + const adapter = createRuntimeAdapter(new Map()); | |
| 138 | 125 | |
| 139 | 126 | const result = await runCodeWithAdapter( |
| 140 | 127 | request(` |
| @@ -151,27 +138,28 @@ test("sandboxed runner does not expose host globals", async () => { · 10 unmodified lines | ||
| 151 | 138 | fetch: typeof fetch, |
| 152 | 139 | fsEscape, |
| 153 | 140 | }; |
| 154 | − console.log(JSON.stringify(checks)); | |
| 155 | 141 | if (checks.process !== "undefined") throw new Error("process leaked"); |
| 156 | 142 | if (checks.bun !== "undefined") throw new Error("Bun leaked"); |
| 157 | 143 | if (checks.fetch !== "undefined") throw new Error("fetch leaked"); |
| 158 | 144 | if (checks.fsEscape !== "undefined" && checks.fsEscape !== "blocked") { |
| 159 | 145 | throw new Error("constructor escape leaked"); |
| 160 | 146 | } |
| 161 | − return "ok"; | |
| 147 | + return checks; | |
| 162 | 148 | `), |
| 163 | 149 | adapter, |
| 164 | 150 | ); |
| 165 | 151 | |
| 166 | 152 | expect(result.status).toBe("completed"); |
| 167 | − expect(result.stdout).toContain("\"process\":\"undefined\""); | |
| 168 | − expect(result.stdout).toContain("\"bun\":\"undefined\""); | |
| 169 | − expect(result.stdout).toContain("\"fetch\":\"undefined\""); | |
| 170 | − expect(result.stdout).toMatch(/"fsEscape":"(undefined|blocked)"/); | |
| 153 | + expect(result.result).toEqual({ | |
| 154 | + process: "undefined", | |
| 155 | + bun: "undefined", | |
| 156 | + fetch: "undefined", | |
| 157 | + fsEscape: expect.stringMatching(/^(undefined|blocked)$/), | |
| 158 | + }); | |
| 171 | 159 | }); |
| 172 | 160 | |
| 173 | 161 | test("sandbox blocks global constructor and eval escapes", async () => { |
| 174 | − const adapter = createRuntimeAdapter(new Map(), []); | |
| 162 | + const adapter = createRuntimeAdapter(new Map()); | |
| 175 | 163 | |
| 176 | 164 | const result = await runCodeWithAdapter( |
| 177 | 165 | request(` |
| @@ -197,25 +185,27 @@ test("sandbox blocks global constructor and eval escapes", async () => { · 19 unmodified lines | ||
| 197 | 185 | functionEscape = "blocked"; |
| 198 | 186 | } |
| 199 | 187 | |
| 200 | − console.log(JSON.stringify({ ctorEscape, evalEscape, functionEscape })); | |
| 201 | 188 | if (ctorEscape !== "undefined" && ctorEscape !== "blocked") throw new Error("ctor escape"); |
| 202 | 189 | if (evalEscape !== "undefined" && evalEscape !== "blocked") throw new Error("eval escape"); |
| 203 | 190 | if (functionEscape !== "undefined" && functionEscape !== "blocked") throw new Error("Function escape"); |
| 191 | + return { ctorEscape, evalEscape, functionEscape }; | |
| 204 | 192 | `), |
| 205 | 193 | adapter, |
| 206 | 194 | ); |
| 207 | 195 | |
| 208 | 196 | expect(result.status).toBe("completed"); |
| 209 | − expect(result.stdout).toMatch(/"ctorEscape":"(undefined|blocked)"/); | |
| 210 | − expect(result.stdout).toMatch(/"evalEscape":"(undefined|blocked)"/); | |
| 211 | − expect(result.stdout).toMatch(/"functionEscape":"(undefined|blocked)"/); | |
| 197 | + expect(result.result).toEqual({ | |
| 198 | + ctorEscape: expect.stringMatching(/^(undefined|blocked)$/), | |
| 199 | + evalEscape: expect.stringMatching(/^(undefined|blocked)$/), | |
| 200 | + functionEscape: expect.stringMatching(/^(undefined|blocked)$/), | |
| 201 | + }); | |
| 212 | 202 | }); |
| 213 | 203 | |
| 214 | 204 | test("sandbox prototype pollution does not leak to host", async () => { |
| 215 | 205 | const marker = `__pwned_${crypto.randomUUID().replace(/-/g, "")}`; |
| 216 | 206 | expect(({} as Record<string, unknown>)[marker]).toBeUndefined(); |
| 217 | 207 | |
| 218 | − const adapter = createRuntimeAdapter(new Map(), []); | |
| 208 | + const adapter = createRuntimeAdapter(new Map()); | |
| 219 | 209 | const result = await runCodeWithAdapter( |
| 220 | 210 | request(` |
| 221 | 211 | Object.prototype[${JSON.stringify(marker)}] = "yes"; |
| @@ -359,8 +349,6 @@ test("runs openapi and graphql sourced tools through the runtime", async () => { · 137 unmodified lines | ||
| 359 | 349 | ]); |
| 360 | 350 | |
| 361 | 351 | const toolMap = new Map(tools.map((tool) => [tool.path, tool])); |
| 362 | − const outputEvents: RuntimeOutputEvent[] = []; | |
| 363 | − | |
| 364 | 352 | const result = await runCodeWithAdapter( |
| 365 | 353 | request(` |
| 366 | 354 | const sum = await tools.calc.math.add_numbers({ a: 2, b: 5 }); |
| @@ -369,20 +357,18 @@ test("runs openapi and graphql sourced tools through the runtime", async () => { · 2 unmodified lines | ||
| 369 | 357 | query: "mutation($value: Int!) { increment(value: $value) }", |
| 370 | 358 | variables: { value: 3 }, |
| 371 | 359 | }); |
| 372 | − console.log("sum", sum.sum); | |
| 373 | − console.log("hello", hello.data); | |
| 374 | − console.log("inc", inc.data.increment); | |
| 375 | − return sum.sum + inc.data.increment; | |
| 360 | + return { | |
| 361 | + sum: sum.sum, | |
| 362 | + hello: hello.data.hello, | |
| 363 | + inc: inc.data.increment, | |
| 364 | + total: sum.sum + inc.data.increment, | |
| 365 | + }; | |
| 376 | 366 | `), |
| 377 | − createRuntimeAdapter(toolMap, outputEvents), | |
| 367 | + createRuntimeAdapter(toolMap), | |
| 378 | 368 | ); |
| 379 | 369 | |
| 380 | 370 | expect(result.status).toBe("completed"); |
| 381 | − expect(result.stdout).toContain("sum 7"); | |
| 382 | − expect(result.stdout).toContain("hello world"); | |
| 383 | − expect(result.stdout).toContain("inc 4"); | |
| 384 | − expect(result.stdout).toContain("result: 11"); | |
| 385 | − expect(outputEvents.length).toBeGreaterThan(0); | |
| 371 | + expect(result.result).toMatchObject({ sum: 7, inc: 4, total: 11 }); | |
| 386 | 372 | } finally { |
| 387 | 373 | server.stop(true); |
| 388 | 374 | } |
| @@ -2,7 +2,7 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | // NOTE: The Vercel sandbox runtime (vercel-sandbox-runtime.ts) contains a JS |
| 4 | 4 | // string version of similar logic, built from sandbox-fragments.ts. Changes to |
| 5 | −// the core helpers here (formatArgs, createToolsProxy, console proxy, execution | |
| 5 | +// the core helpers here (createToolsProxy, console proxy, execution | |
| 6 | 6 | // loop, result mapping) should be mirrored there. |
| 7 | 7 | import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution-constants"; |
| 8 | 8 | import { Result } from "better-result"; |
| @@ -14,23 +14,25 @@ import type { · 5 unmodified lines | ||
| 14 | 14 | } from "../types"; |
| 15 | 15 | import { transpileForRuntime } from "./transpile"; |
| 16 | 16 | |
| 17 | −function formatArgs(args: unknown[]): string { | |
| 18 | − return args | |
| 19 | − .map((value) => { | |
| 20 | − if (typeof value === "string") return value; | |
| 21 | − return Result.try(() => JSON.stringify(value)).unwrapOr(String(value)); | |
| 22 | − }) | |
| 23 | − .join(" "); | |
| 17 | +async function sleep(ms: number): Promise<void> { | |
| 18 | + await new Promise((resolve) => setTimeout(resolve, ms)); | |
| 24 | 19 | } |
| 25 | 20 | |
| 26 | −function fireAndForget(promise: void | Promise<void>): void { | |
| 27 | − if (promise && typeof promise === "object" && "then" in promise) { | |
| 28 | − void (promise as Promise<void>).catch(() => {}); | |
| 21 | +function sanitizeExecutionResult(value: unknown): unknown { | |
| 22 | + if (value === undefined) { | |
| 23 | + return undefined; | |
| 29 | 24 | } |
| 30 | −} | |
| 31 | 25 | |
| 32 | −async function sleep(ms: number): Promise<void> { | |
| 33 | − await new Promise((resolve) => setTimeout(resolve, ms)); | |
| 26 | + const serialized = Result.try(() => JSON.stringify(value)); | |
| 27 | + if (serialized.isErr()) { | |
| 28 | + return String(value); | |
| 29 | + } | |
| 30 | + | |
| 31 | + if (serialized.value === undefined) { | |
| 32 | + return null; | |
| 33 | + } | |
| 34 | + | |
| 35 | + return Result.try(() => JSON.parse(serialized.value) as unknown).unwrapOr(String(value)); | |
| 34 | 36 | } |
| 35 | 37 | |
| 36 | 38 | function createToolsProxy( |
| @@ -110,40 +112,13 @@ export async function runCodeWithAdapter( · 73 unmodified lines | ||
| 110 | 112 | adapter: ExecutionAdapter, |
| 111 | 113 | ): Promise<SandboxExecutionResult> { |
| 112 | 114 | const startedAt = Date.now(); |
| 113 | − const stdoutLines: string[] = []; | |
| 114 | − const stderrLines: string[] = []; | |
| 115 | − | |
| 116 | − const appendStdout = (line: string): void => { | |
| 117 | − stdoutLines.push(line); | |
| 118 | − fireAndForget( | |
| 119 | − adapter.emitOutput({ | |
| 120 | − runId: request.taskId, | |
| 121 | − stream: "stdout", | |
| 122 | − line, | |
| 123 | − timestamp: Date.now(), | |
| 124 | − }), | |
| 125 | − ); | |
| 126 | − }; | |
| 127 | − | |
| 128 | − const appendStderr = (line: string): void => { | |
| 129 | − stderrLines.push(line); | |
| 130 | − fireAndForget( | |
| 131 | − adapter.emitOutput({ | |
| 132 | − runId: request.taskId, | |
| 133 | − stream: "stderr", | |
| 134 | − line, | |
| 135 | − timestamp: Date.now(), | |
| 136 | − }), | |
| 137 | − ); | |
| 138 | − }; | |
| 139 | 115 | |
| 140 | 116 | const mkResult = ( |
| 141 | 117 | status: SandboxExecutionResult["status"], |
| 142 | − opts?: { error?: string; exitCode?: number }, | |
| 118 | + opts?: { error?: string; exitCode?: number; result?: unknown }, | |
| 143 | 119 | ): SandboxExecutionResult => ({ |
| 144 | 120 | status, |
| 145 | − stdout: stdoutLines.join("\n"), | |
| 146 | − stderr: stderrLines.join("\n"), | |
| 121 | + result: opts?.result, | |
| 147 | 122 | exitCode: opts?.exitCode, |
| 148 | 123 | error: opts?.error, |
| 149 | 124 | durationMs: Date.now() - startedAt, |
| @@ -152,17 +127,16 @@ export async function runCodeWithAdapter( · 2 unmodified lines | ||
| 152 | 127 | // ── Transpile ────────────────────────────────────────────────────────── |
| 153 | 128 | const transpiled = transpileForRuntime(request.code); |
| 154 | 129 | if (transpiled.isErr()) { |
| 155 | − appendStderr(transpiled.error.message); | |
| 156 | 130 | return mkResult("failed", { error: transpiled.error.message }); |
| 157 | 131 | } |
| 158 | 132 | |
| 159 | 133 | // ── Sandbox setup ────────────────────────────────────────────────────── |
| 160 | 134 | const tools = createToolsProxy(adapter, request.taskId); |
| 161 | 135 | const consoleProxy = { |
| 162 | − log: (...args: unknown[]) => appendStdout(formatArgs(args)), | |
| 163 | − info: (...args: unknown[]) => appendStdout(formatArgs(args)), | |
| 164 | − warn: (...args: unknown[]) => appendStderr(formatArgs(args)), | |
| 165 | − error: (...args: unknown[]) => appendStderr(formatArgs(args)), | |
| 136 | + log: (..._args: unknown[]) => {}, | |
| 137 | + info: (..._args: unknown[]) => {}, | |
| 138 | + warn: (..._args: unknown[]) => {}, | |
| 139 | + error: (..._args: unknown[]) => {}, | |
| 166 | 140 | }; |
| 167 | 141 | |
| 168 | 142 | const sandbox = Object.assign(Object.create(null), { |
| @@ -206,13 +180,11 @@ export async function runCodeWithAdapter( · 37 unmodified lines | ||
| 206 | 180 | execution.error.cause, |
| 207 | 181 | request, |
| 208 | 182 | ); |
| 209 | − appendStderr(message); | |
| 210 | 183 | return mkResult(status, { error: message }); |
| 211 | 184 | } |
| 212 | 185 | |
| 213 | − if (execution.value !== undefined) { | |
| 214 | − appendStdout(`result: ${formatArgs([execution.value])}`); | |
| 215 | − } | |
| 216 | − | |
| 217 | − return mkResult("completed", { exitCode: 0 }); | |
| 186 | + return mkResult("completed", { | |
| 187 | + exitCode: 0, | |
| 188 | + result: sanitizeExecutionResult(execution.value), | |
| 189 | + }); | |
| 218 | 190 | } |
| @@ -14,14 +14,9 @@ export class TranspileError extends TaggedError("TranspileError")<{ · 13 unmodified lines | ||
| 14 | 14 | // TypeScript module loader |
| 15 | 15 | // --------------------------------------------------------------------------- |
| 16 | 16 | |
| 17 | −let cachedTypeScript: typeof import("typescript") | null | undefined; | |
| 18 | − | |
| 19 | 17 | function getTypeScriptModule(): typeof import("typescript") | null { |
| 20 | − if (cachedTypeScript === undefined) { | |
| 21 | − const loaded = Result.try(() => require("typescript") as typeof import("typescript")); | |
| 22 | − cachedTypeScript = loaded.isOk() ? loaded.value : null; | |
| 23 | − } | |
| 24 | − return cachedTypeScript ?? null; | |
| 18 | + const loaded = Result.try(() => require("typescript") as typeof import("typescript")); | |
| 19 | + return loaded.isOk() ? loaded.value : null; | |
| 25 | 20 | } |
| 26 | 21 | |
| 27 | 22 | // --------------------------------------------------------------------------- |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import type { ToolDefinition } from "./types"; |
| 2 | 2 | import { |
| 3 | + compactArgKeysHint, | |
| 3 | 4 | compactArgTypeHint, |
| 4 | 5 | compactDescriptionLine, |
| 5 | 6 | compactReturnTypeHint, |
| @@ -74,7 +75,7 @@ function buildExampleCall(entry: DiscoverIndexEntry): string { · 68 unmodified lines | ||
| 74 | 75 | |
| 75 | 76 | const keys = entry.argPreviewKeys.length > 0 ? entry.argPreviewKeys : extractTopLevelTypeKeys(entry.argsType); |
| 76 | 77 | if (keys.length > 0) { |
| 77 | − const argsSnippet = keys.slice(0, 3) | |
| 78 | + const argsSnippet = keys.slice(0, 5) | |
| 78 | 79 | .map((key) => `${key}: ${key.toLowerCase().includes("input") ? "{ /* ... */ }" : "..."}`) |
| 79 | 80 | .join(", "); |
| 80 | 81 | return `await tools.${entry.path}({ ${argsSnippet} });`; |
| @@ -214,7 +215,9 @@ function formatSignature(entry: DiscoverIndexEntry, depth: number, compact: bool · 133 unmodified lines | ||
| 214 | 215 | return "(input: ...): Promise<...>"; |
| 215 | 216 | } |
| 216 | 217 | |
| 217 | − const args = compactArgTypeHint(entry.argsType); | |
| 218 | + const args = entry.argPreviewKeys.length > 0 | |
| 219 | + ? compactArgKeysHint(entry.argPreviewKeys) | |
| 220 | + : compactArgTypeHint(entry.argsType); | |
| 218 | 221 | const returns = compactReturnTypeHint(entry.returnsType); |
| 219 | 222 | |
| 220 | 223 | if (depth === 1) { |
| @@ -526,6 +526,115 @@ function jsonSchemaTypeHintFallback( · 525 unmodified lines | ||
| 526 | 526 | return "unknown"; |
| 527 | 527 | } |
| 528 | 528 | |
| 529 | +type OpenApiParameterHint = { | |
| 530 | + name: string; | |
| 531 | + required: boolean; | |
| 532 | + schema: Record<string, unknown>; | |
| 533 | +}; | |
| 534 | + | |
| 535 | +function pushUnique(values: string[], seen: Set<string>, raw: string): void { | |
| 536 | + const value = raw.trim(); | |
| 537 | + if (!value || seen.has(value)) return; | |
| 538 | + seen.add(value); | |
| 539 | + values.push(value); | |
| 540 | +} | |
| 541 | + | |
| 542 | +function collectTopLevelSchemaKeys( | |
| 543 | + schema: unknown, | |
| 544 | + componentSchemas?: Record<string, unknown>, | |
| 545 | + seenRefs: Set<string> = new Set(), | |
| 546 | +): string[] { | |
| 547 | + if (!schema || typeof schema !== "object") return []; | |
| 548 | + | |
| 549 | + const record = schema as Record<string, unknown>; | |
| 550 | + const ref = typeof record.$ref === "string" ? record.$ref : ""; | |
| 551 | + if (ref.startsWith("#/components/schemas/")) { | |
| 552 | + if (seenRefs.has(ref)) return []; | |
| 553 | + const key = ref.slice("#/components/schemas/".length); | |
| 554 | + const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {}; | |
| 555 | + if (Object.keys(resolved).length === 0) return []; | |
| 556 | + const nextSeen = new Set(seenRefs); | |
| 557 | + nextSeen.add(ref); | |
| 558 | + return collectTopLevelSchemaKeys(resolved, componentSchemas, nextSeen); | |
| 559 | + } | |
| 560 | + | |
| 561 | + const keys: string[] = []; | |
| 562 | + const seen = new Set<string>(); | |
| 563 | + | |
| 564 | + for (const key of Object.keys(asRecord(record.properties))) { | |
| 565 | + pushUnique(keys, seen, key); | |
| 566 | + } | |
| 567 | + | |
| 568 | + const combinators: unknown[] = [ | |
| 569 | + ...(Array.isArray(record.allOf) ? record.allOf : []), | |
| 570 | + ...(Array.isArray(record.oneOf) ? record.oneOf : []), | |
| 571 | + ...(Array.isArray(record.anyOf) ? record.anyOf : []), | |
| 572 | + ]; | |
| 573 | + | |
| 574 | + for (const entry of combinators) { | |
| 575 | + for (const key of collectTopLevelSchemaKeys(entry, componentSchemas, seenRefs)) { | |
| 576 | + pushUnique(keys, seen, key); | |
| 577 | + } | |
| 578 | + } | |
| 579 | + | |
| 580 | + return keys; | |
| 581 | +} | |
| 582 | + | |
| 583 | +function buildOpenApiInputSchema( | |
| 584 | + parameters: OpenApiParameterHint[], | |
| 585 | + requestBodySchema: Record<string, unknown>, | |
| 586 | +): JsonSchema { | |
| 587 | + const hasBodySchema = Object.keys(requestBodySchema).length > 0; | |
| 588 | + const hasParams = parameters.length > 0; | |
| 589 | + | |
| 590 | + if (!hasBodySchema && !hasParams) { | |
| 591 | + return {}; | |
| 592 | + } | |
| 593 | + | |
| 594 | + const parameterSchema: JsonSchema = { | |
| 595 | + type: "object", | |
| 596 | + properties: Object.fromEntries(parameters.map((param) => [param.name, param.schema])), | |
| 597 | + required: parameters.filter((param) => param.required).map((param) => param.name), | |
| 598 | + }; | |
| 599 | + | |
| 600 | + if (!hasBodySchema) { | |
| 601 | + return parameterSchema; | |
| 602 | + } | |
| 603 | + | |
| 604 | + if (!hasParams) { | |
| 605 | + return requestBodySchema; | |
| 606 | + } | |
| 607 | + | |
| 608 | + return { | |
| 609 | + allOf: [parameterSchema, requestBodySchema], | |
| 610 | + }; | |
| 611 | +} | |
| 612 | + | |
| 613 | +function buildOpenApiArgPreviewKeys( | |
| 614 | + parameters: OpenApiParameterHint[], | |
| 615 | + requestBodySchema: Record<string, unknown>, | |
| 616 | + componentSchemas?: Record<string, unknown>, | |
| 617 | +): string[] { | |
| 618 | + const keys: string[] = []; | |
| 619 | + const seen = new Set<string>(); | |
| 620 | + | |
| 621 | + for (const parameter of parameters) { | |
| 622 | + if (!parameter.required) continue; | |
| 623 | + pushUnique(keys, seen, parameter.name); | |
| 624 | + } | |
| 625 | + | |
| 626 | + for (const key of collectTopLevelSchemaKeys(requestBodySchema, componentSchemas)) { | |
| 627 | + pushUnique(keys, seen, key); | |
| 628 | + } | |
| 629 | + | |
| 630 | + for (const parameter of parameters) { | |
| 631 | + if (parameter.required) continue; | |
| 632 | + pushUnique(keys, seen, parameter.name); | |
| 633 | + } | |
| 634 | + | |
| 635 | + return keys; | |
| 636 | +} | |
| 637 | + | |
| 529 | 638 | async function connectMcp( |
| 530 | 639 | url: string, |
| 531 | 640 | queryParams: Record<string, string> | undefined, |
| @@ -908,7 +1017,7 @@ function compactOpenApiPaths( · 376 unmodified lines | ||
| 908 | 1017 | return entry; |
| 909 | 1018 | }; |
| 910 | 1019 | |
| 911 | − const normalizeParameters = (entries: unknown): Array<Record<string, unknown>> => { | |
| 1020 | + const normalizeParameters = (entries: unknown): Array<OpenApiParameterHint & { in: string }> => { | |
| 912 | 1021 | if (!Array.isArray(entries)) return []; |
| 913 | 1022 | return entries |
| 914 | 1023 | .map((entry) => resolveParam(asRecord(entry))) |
| @@ -982,32 +1091,12 @@ function compactOpenApiPaths( · 67 unmodified lines | ||
| 982 | 1091 | const mergedParameters = normalizeParameters(operation.parameters).concat(sharedParameters); |
| 983 | 1092 | const hasInputSchema = |
| 984 | 1093 | mergedParameters.length > 0 || Object.keys(requestBodySchema).length > 0; |
| 985 | − const combinedSchema: JsonSchema = { | |
| 986 | − type: "object", | |
| 987 | − properties: { | |
| 988 | − ...Object.fromEntries( | |
| 989 | − mergedParameters | |
| 990 | − .map((param) => [param.name, param.schema]), | |
| 991 | − ), | |
| 992 | − ...asRecord(requestBodySchema.properties), | |
| 993 | − }, | |
| 994 | − required: [ | |
| 995 | − ...mergedParameters | |
| 996 | − .filter((param) => param.required) | |
| 997 | − .map((param) => param.name as string), | |
| 998 | − ...((Array.isArray(requestBodySchema.required) | |
| 999 | − ? requestBodySchema.required.filter((item): item is string => typeof item === "string") | |
| 1000 | − : []) as string[]), | |
| 1001 | − ], | |
| 1002 | − }; | |
| 1094 | + const combinedSchema = buildOpenApiInputSchema(mergedParameters, requestBodySchema); | |
| 1003 | 1095 | compactOperation._argsTypeHint = hasInputSchema |
| 1004 | 1096 | ? jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas) |
| 1005 | 1097 | : "{}"; |
| 1006 | 1098 | compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas); |
| 1007 | − const previewKeys = [ | |
| 1008 | − ...mergedParameters.map((param) => String(param.name ?? "")).filter((name) => name.length > 0), | |
| 1009 | − ...Object.keys(asRecord(requestBodySchema.properties)), | |
| 1010 | − ]; | |
| 1099 | + const previewKeys = buildOpenApiArgPreviewKeys(mergedParameters, requestBodySchema, compSchemas); | |
| 1011 | 1100 | if (previewKeys.length > 0) { |
| 1012 | 1101 | compactOperation._argPreviewKeys = [...new Set(previewKeys)]; |
| 1013 | 1102 | } |
| @@ -1254,29 +1343,13 @@ export function buildOpenApiToolsFromPrepared( · 240 unmodified lines | ||
| 1254 | 1343 | if (Object.keys(responseSchema).length > 0) break; |
| 1255 | 1344 | } |
| 1256 | 1345 | |
| 1257 | − const combinedSchema: JsonSchema = { | |
| 1258 | − type: "object", | |
| 1259 | − properties: { | |
| 1260 | − ...Object.fromEntries(parameters.map((param) => [param.name, param.schema])), | |
| 1261 | − ...asRecord(requestBodySchema.properties), | |
| 1262 | − }, | |
| 1263 | − required: [ | |
| 1264 | − ...parameters.filter((param) => param.required).map((param) => param.name), | |
| 1265 | − ...((Array.isArray(requestBodySchema.required) | |
| 1266 | − ? requestBodySchema.required.filter((item): item is string => typeof item === "string") | |
| 1267 | − : []) as string[]), | |
| 1268 | − ], | |
| 1269 | − }; | |
| 1270 | − | |
| 1271 | 1346 | const hasInputSchema = parameters.length > 0 || Object.keys(requestBodySchema).length > 0; |
| 1347 | + const combinedSchema = buildOpenApiInputSchema(parameters, requestBodySchema); | |
| 1272 | 1348 | |
| 1273 | 1349 | argsType = hasInputSchema ? jsonSchemaTypeHintFallback(combinedSchema) : "{}"; |
| 1274 | 1350 | returnsType = responseTypeHintFromSchema(responseSchema, responseStatus); |
| 1275 | 1351 | if (argPreviewKeys.length === 0) { |
| 1276 | − argPreviewKeys = [ | |
| 1277 | − ...parameters.map((param) => param.name), | |
| 1278 | − ...Object.keys(asRecord(requestBodySchema.properties)), | |
| 1279 | − ].filter((name, index, all) => name.length > 0 && all.indexOf(name) === index); | |
| 1352 | + argPreviewKeys = buildOpenApiArgPreviewKeys(parameters, requestBodySchema); | |
| 1280 | 1353 | } |
| 1281 | 1354 | } |
| 1282 | 1355 | |
| @@ -307,6 +307,54 @@ export interface operations { · 306 unmodified lines | ||
| 307 | 307 | expect(bad.errors.length).toBeGreaterThan(0); |
| 308 | 308 | }); |
| 309 | 309 | |
| 310 | + test("OpenAPI declarations intersect strict fallback args with indexed-access inputs", () => { | |
| 311 | + const tools: ToolDescriptor[] = [ | |
| 312 | + { | |
| 313 | + path: "vercel.dns.create_record", | |
| 314 | + description: "Create DNS record", | |
| 315 | + approval: "auto", | |
| 316 | + source: "openapi:vercel", | |
| 317 | + operationId: "createRecord", | |
| 318 | + strictArgsType: "{ domain: string; type: \"CNAME\" | \"TXT\"; name: string; value: string }", | |
| 319 | + }, | |
| 320 | + ]; | |
| 321 | + | |
| 322 | + const sourceDts = ` | |
| 323 | +export interface operations { | |
| 324 | + createRecord: { | |
| 325 | + parameters: { path: { domain: string } }; | |
| 326 | + requestBody: { | |
| 327 | + content: { | |
| 328 | + "application/json": | |
| 329 | + | { type: "A" | "CNAME" | "TXT" } | |
| 330 | + | { type: "CNAME"; name: string; value: string }; | |
| 331 | + }; | |
| 332 | + }; | |
| 333 | + responses: { 200: { content: { "application/json": { id: string } } } }; | |
| 334 | + }; | |
| 335 | +} | |
| 336 | +`; | |
| 337 | + | |
| 338 | + const declarations = generateToolDeclarations(tools, { | |
| 339 | + sourceDtsBySource: { | |
| 340 | + "openapi:vercel": sourceDts, | |
| 341 | + }, | |
| 342 | + }); | |
| 343 | + | |
| 344 | + const ok = typecheckCode( | |
| 345 | + 'await tools.vercel.dns.create_record({ domain: "executor.sh", type: "CNAME", name: "api", value: "convex.domains" });', | |
| 346 | + declarations, | |
| 347 | + ); | |
| 348 | + expect(ok.ok).toBe(true); | |
| 349 | + | |
| 350 | + const missing = typecheckCode( | |
| 351 | + 'await tools.vercel.dns.create_record({ domain: "executor.sh", type: "CNAME" });', | |
| 352 | + declarations, | |
| 353 | + ); | |
| 354 | + expect(missing.ok).toBe(false); | |
| 355 | + expect(missing.errors.some((error) => error.includes("name") || error.includes("value"))).toBe(true); | |
| 356 | + }); | |
| 357 | + | |
| 310 | 358 | test("OpenAPI helper types infer vendor JSON response payloads", () => { |
| 311 | 359 | const tools: ToolDescriptor[] = [ |
| 312 | 360 | { |
| @@ -464,4 +512,3 @@ export interface operations { · 151 unmodified lines | ||
| 464 | 512 | expect(result.errors).toHaveLength(0); |
| 465 | 513 | }); |
| 466 | 514 | }); |
| 467 | − | |
| @@ -20,14 +20,9 @@ import type { ToolDescriptor } from "./types"; · 19 unmodified lines | ||
| 20 | 20 | // Tool declarations generation |
| 21 | 21 | // --------------------------------------------------------------------------- |
| 22 | 22 | |
| 23 | −let cachedTypeScript: typeof import("typescript") | null | undefined; | |
| 24 | − | |
| 25 | 23 | function getTypeScriptModule(): typeof import("typescript") | null { |
| 26 | − if (cachedTypeScript === undefined) { | |
| 27 | − const loaded = Result.try(() => require("typescript") as typeof import("typescript")); | |
| 28 | − cachedTypeScript = loaded.isOk() ? loaded.value : null; | |
| 29 | − } | |
| 30 | − return cachedTypeScript ?? null; | |
| 24 | + const loaded = Result.try(() => require("typescript") as typeof import("typescript")); | |
| 25 | + return loaded.isOk() ? loaded.value : null; | |
| 31 | 26 | } |
| 32 | 27 | |
| 33 | 28 | function isValidTypeExpression(typeExpression: string): boolean { |
| @@ -175,7 +170,12 @@ export function generateToolDeclarations( · 141 unmodified lines | ||
| 175 | 170 | const tool = child.tool; |
| 176 | 171 | if (tool.operationId && tool.source && dtsSources.has(tool.source)) { |
| 177 | 172 | const opKey = JSON.stringify(tool.operationId); |
| 178 | − lines.push(`${pad}${key}(input: ToolInput<operations[${opKey}]>): Promise<ToolOutput<operations[${opKey}]>>;`); | |
| 173 | + const openApiInputType = `ToolInput<operations[${opKey}]>`; | |
| 174 | + const strictArgsType = safeTypeExpression(tool.strictArgsType, "{}"); | |
| 175 | + const inputType = strictArgsType !== "{}" | |
| 176 | + ? `(${openApiInputType}) & (${strictArgsType})` | |
| 177 | + : openApiInputType; | |
| 178 | + lines.push(`${pad}${key}(input: ${inputType}): Promise<ToolOutput<operations[${opKey}]>>;`); | |
| 179 | 179 | } else { |
| 180 | 180 | const strictArgsType = tool.strictArgsType?.trim(); |
| 181 | 181 | const strictReturnsType = tool.strictReturnsType?.trim(); |
| @@ -7,10 +7,19 @@ export type ApprovalStatus = "pending" | "approved" | "denied"; · 6 unmodified lines | ||
| 7 | 7 | export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied"; |
| 8 | 8 | export type PolicyDecision = "allow" | "require_approval" | "deny"; |
| 9 | 9 | export type CredentialScope = "workspace" | "actor"; |
| 10 | −export type CredentialProvider = "managed" | "workos-vault"; | |
| 10 | +export type CredentialProvider = "local-convex" | "workos-vault"; | |
| 11 | 11 | export type ToolApprovalMode = "auto" | "required"; |
| 12 | 12 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; |
| 13 | 13 | |
| 14 | +export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed"; | |
| 15 | + | |
| 16 | +export interface SourceAuthProfile { | |
| 17 | + type: SourceAuthType; | |
| 18 | + mode?: CredentialScope; | |
| 19 | + header?: string; | |
| 20 | + inferred: boolean; | |
| 21 | +} | |
| 22 | + | |
| 14 | 23 | export interface TaskRecord { |
| 15 | 24 | id: string; |
| 16 | 25 | code: string; |
| @@ -26,8 +35,7 @@ export interface TaskRecord { · 9 unmodified lines | ||
| 26 | 35 | startedAt?: number; |
| 27 | 36 | completedAt?: number; |
| 28 | 37 | error?: string; |
| 29 | − stdout?: string; | |
| 30 | − stderr?: string; | |
| 38 | + result?: unknown; | |
| 31 | 39 | exitCode?: number; |
| 32 | 40 | } |
| 33 | 41 | |
| @@ -61,10 +69,8 @@ export interface ToolCallRecord { · 27 unmodified lines | ||
| 61 | 69 | callId: string; |
| 62 | 70 | workspaceId: Id<"workspaces">; |
| 63 | 71 | toolPath: string; |
| 64 | − input: unknown; | |
| 65 | 72 | status: ToolCallStatus; |
| 66 | 73 | approvalId?: string; |
| 67 | − output?: unknown; | |
| 68 | 74 | error?: string; |
| 69 | 75 | createdAt: number; |
| 70 | 76 | updatedAt: number; |
| @@ -85,10 +91,13 @@ export interface AccessPolicyRecord { · 14 unmodified lines | ||
| 85 | 91 | |
| 86 | 92 | export interface CredentialRecord { |
| 87 | 93 | id: string; |
| 94 | + bindingId?: string; | |
| 88 | 95 | workspaceId: Id<"workspaces">; |
| 89 | 96 | sourceKey: string; |
| 90 | 97 | scope: CredentialScope; |
| 91 | 98 | actorId?: string; |
| 99 | + overridesJson?: Record<string, unknown>; | |
| 100 | + boundAuthFingerprint?: string; | |
| 92 | 101 | provider: CredentialProvider; |
| 93 | 102 | secretJson: Record<string, unknown>; |
| 94 | 103 | createdAt: number; |
| @@ -167,8 +176,7 @@ export interface SandboxExecutionRequest { · 72 unmodified lines | ||
| 167 | 176 | |
| 168 | 177 | export interface SandboxExecutionResult { |
| 169 | 178 | status: Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">; |
| 170 | − stdout: string; | |
| 171 | − stderr: string; | |
| 179 | + result?: unknown; | |
| 172 | 180 | exitCode?: number; |
| 173 | 181 | error?: string; |
| 174 | 182 | durationMs: number; |
| @@ -193,18 +201,8 @@ export type ToolCallResult = · 18 unmodified lines | ||
| 193 | 201 | | { ok: false; kind: "denied"; error: string } |
| 194 | 202 | | { ok: false; kind: "failed"; error: string }; |
| 195 | 203 | |
| 196 | −export type RuntimeOutputStream = "stdout" | "stderr"; | |
| 197 | − | |
| 198 | −export interface RuntimeOutputEvent { | |
| 199 | − runId: string; | |
| 200 | − stream: RuntimeOutputStream; | |
| 201 | − line: string; | |
| 202 | − timestamp: number; | |
| 203 | −} | |
| 204 | − | |
| 205 | 204 | export interface ExecutionAdapter { |
| 206 | 205 | invokeTool(call: ToolCallRequest): Promise<ToolCallResult>; |
| 207 | − emitOutput(event: RuntimeOutputEvent): void | Promise<void>; | |
| 208 | 206 | } |
| 209 | 207 | |
| 210 | 208 | export interface SandboxRuntime { |
| @@ -17,9 +17,8 @@ · 16 unmodified lines | ||
| 17 | 17 | * (passed as a loopback service binding via `ctx.exports`) which invokes |
| 18 | 18 | * Convex callback RPC functions to resolve them. |
| 19 | 19 | * |
| 20 | − * 4. Console output is buffered in the harness and returned in the response. | |
| 21 | − * Output lines are also streamed back to Convex in real-time via the | |
| 22 | − * ToolBridge binding. | |
| 20 | + * 4. Console output is intentionally discarded. Only explicit `return` values | |
| 21 | + * are included in terminal run results. | |
| 23 | 22 | * |
| 24 | 23 | * 5. `/v1/runs` returns an accepted dispatch response immediately. Terminal |
| 25 | 24 | * result status is reported back to Convex through callback RPC. |
| @@ -89,8 +88,7 @@ interface RunRequest { · 63 unmodified lines | ||
| 89 | 88 | |
| 90 | 89 | interface RunResult { |
| 91 | 90 | status: "completed" | "failed" | "timed_out" | "denied"; |
| 92 | − stdout: string; | |
| 93 | − stderr: string; | |
| 91 | + result?: unknown; | |
| 94 | 92 | error?: string; |
| 95 | 93 | exitCode?: number; |
| 96 | 94 | } |
| @@ -143,8 +141,6 @@ function timingSafeEqual(a: string, b: string): boolean { · 46 unmodified lines | ||
| 143 | 141 | |
| 144 | 142 | const failedResult = (error: string): RunResult => ({ |
| 145 | 143 | status: "failed", |
| 146 | − stdout: "", | |
| 147 | − stderr: "", | |
| 148 | 144 | error, |
| 149 | 145 | }); |
| 150 | 146 | |
| @@ -198,21 +194,6 @@ export class ToolBridge extends WorkerEntrypoint<Env> { · 47 unmodified lines | ||
| 198 | 194 | |
| 199 | 195 | return response.value as ToolCallResult; |
| 200 | 196 | } |
| 201 | − | |
| 202 | − /** Stream a console output line back to Convex (best-effort). */ | |
| 203 | − async emitOutput(stream: "stdout" | "stderr", line: string): Promise<void> { | |
| 204 | − const { callbackInternalSecret, taskId } = this.props; | |
| 205 | − await Result.tryPromise(async () => { | |
| 206 | − const convex = this.createConvexClient(); | |
| 207 | − await convex.mutation(api.runtimeCallbacks.appendOutput, { | |
| 208 | − internalSecret: callbackInternalSecret, | |
| 209 | − runId: taskId, | |
| 210 | − stream, | |
| 211 | − line, | |
| 212 | − timestamp: Date.now(), | |
| 213 | − }); | |
| 214 | − }); | |
| 215 | − } | |
| 216 | 197 | } |
| 217 | 198 | |
| 218 | 199 | // ── Sandbox Harness ────────────────────────────────────────────────────────── |
| @@ -285,8 +266,6 @@ async function executeSandboxRun(request: RunRequest, ctx: ExecutionContext, env · 66 unmodified lines | ||
| 285 | 266 | if (cause instanceof DOMException && cause.name === "AbortError") { |
| 286 | 267 | return { |
| 287 | 268 | status: "timed_out", |
| 288 | − stdout: "", | |
| 289 | − stderr: "", | |
| 290 | 269 | error: `Execution timed out after ${timeoutMs}ms`, |
| 291 | 270 | }; |
| 292 | 271 | } |
| @@ -312,8 +291,7 @@ async function reportRunCompletion(request: RunRequest, result: RunResult, durat · 19 unmodified lines | ||
| 312 | 291 | internalSecret: request.callback.internalSecret, |
| 313 | 292 | runId: request.taskId, |
| 314 | 293 | status: result.status, |
| 315 | − stdout: result.stdout, | |
| 316 | − stderr: result.stderr, | |
| 294 | + result: result.result, | |
| 317 | 295 | exitCode: result.exitCode, |
| 318 | 296 | error: result.error, |
| 319 | 297 | durationMs, |
| @@ -3,19 +3,6 @@ import { run } from "./user-code.js"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:"; |
| 5 | 5 | |
| 6 | −function formatArgs(args) { | |
| 7 | − return args | |
| 8 | − .map((v) => { | |
| 9 | − if (typeof v === "string") return v; | |
| 10 | − try { | |
| 11 | − return JSON.stringify(v); | |
| 12 | − } catch { | |
| 13 | − return String(v); | |
| 14 | − } | |
| 15 | − }) | |
| 16 | − .join(" "); | |
| 17 | −} | |
| 18 | − | |
| 19 | 6 | async function sleep(ms) { |
| 20 | 7 | await new Promise((resolve) => setTimeout(resolve, ms)); |
| 21 | 8 | } |
| @@ -48,39 +35,33 @@ function createToolsProxy(bridge, path = []) { · 26 unmodified lines | ||
| 48 | 35 | }); |
| 49 | 36 | } |
| 50 | 37 | |
| 51 | −export default { | |
| 52 | − async fetch(req, env, ctx) { | |
| 53 | − const stdoutLines = []; | |
| 54 | − const stderrLines = []; | |
| 55 | − | |
| 56 | − const appendStdout = (line) => { | |
| 57 | − stdoutLines.push(line); | |
| 58 | − ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stdout", line)); | |
| 59 | − }; | |
| 60 | − const appendStderr = (line) => { | |
| 61 | − stderrLines.push(line); | |
| 62 | − ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stderr", line)); | |
| 63 | − }; | |
| 38 | +function sanitizeExecutionResult(value) { | |
| 39 | + if (value === undefined) return undefined; | |
| 40 | + try { | |
| 41 | + const serialized = JSON.stringify(value); | |
| 42 | + if (serialized === undefined) return null; | |
| 43 | + return JSON.parse(serialized); | |
| 44 | + } catch { | |
| 45 | + return String(value); | |
| 46 | + } | |
| 47 | +} | |
| 64 | 48 | |
| 49 | +export default { | |
| 50 | + async fetch(req, env, _ctx) { | |
| 65 | 51 | const tools = createToolsProxy(env.TOOL_BRIDGE); |
| 66 | 52 | const console = { |
| 67 | − log: (...args) => appendStdout(formatArgs(args)), | |
| 68 | − info: (...args) => appendStdout(formatArgs(args)), | |
| 69 | − warn: (...args) => appendStderr(formatArgs(args)), | |
| 70 | − error: (...args) => appendStderr(formatArgs(args)), | |
| 53 | + log: (..._args) => {}, | |
| 54 | + info: (..._args) => {}, | |
| 55 | + warn: (..._args) => {}, | |
| 56 | + error: (..._args) => {}, | |
| 71 | 57 | }; |
| 72 | 58 | |
| 73 | 59 | try { |
| 74 | 60 | const value = await run(tools, console); |
| 75 | 61 | |
| 76 | − if (value !== undefined) { | |
| 77 | − appendStdout("result: " + formatArgs([value])); | |
| 78 | − } | |
| 79 | − | |
| 80 | 62 | return _ResponseJson({ |
| 81 | 63 | status: "completed", |
| 82 | − stdout: stdoutLines.join("\n"), | |
| 83 | − stderr: stderrLines.join("\n"), | |
| 64 | + result: sanitizeExecutionResult(value), | |
| 84 | 65 | exitCode: 0, |
| 85 | 66 | }); |
| 86 | 67 | } catch (error) { |
| @@ -88,19 +69,13 @@ export default { · 1 unmodified line | ||
| 88 | 69 | error instanceof Error ? error.message : String(error); |
| 89 | 70 | if (message.startsWith(APPROVAL_DENIED_PREFIX)) { |
| 90 | 71 | const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim(); |
| 91 | − appendStderr(denied); | |
| 92 | 72 | return _ResponseJson({ |
| 93 | 73 | status: "denied", |
| 94 | − stdout: stdoutLines.join("\n"), | |
| 95 | − stderr: stderrLines.join("\n"), | |
| 96 | 74 | error: denied, |
| 97 | 75 | }); |
| 98 | 76 | } |
| 99 | − appendStderr(message); | |
| 100 | 77 | return _ResponseJson({ |
| 101 | 78 | status: "failed", |
| 102 | − stdout: stdoutLines.join("\n"), | |
| 103 | − stderr: stderrLines.join("\n"), | |
| 104 | 79 | error: message, |
| 105 | 80 | }); |
| 106 | 81 | } |