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 | # EXECUTOR_BACKEND_SITE_PORT=5411 | 26 | # EXECUTOR_BACKEND_SITE_PORT=5411 |
| 27 | # EXECUTOR_WEB_PORT=5312 | 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 | # ── Discord bot (optional — omit to skip bot startup) ── | 33 | # ── Discord bot (optional — omit to skip bot startup) ── |
| 30 | # DISCORD_BOT_TOKEN=... | 34 | # DISCORD_BOT_TOKEN=... |
| 31 | 35 | ||
| @@ -10,3 +10,5 @@ dist · 9 unmodified lines | |||
| 10 | .vercel | 10 | .vercel |
| 11 | .env*.local | 11 | .env*.local |
| 12 | .convex | 12 | .convex |
| 13 | **/.wrangler | ||
| 14 | **/.dev.vars | ||
| @@ -121,10 +121,13 @@ Convex HTTP routes (`packages/convex/http.ts`) expose: · 120 unmodified lines | |||
| 121 | - `/mcp` (direct Convex MCP transport) | 121 | - `/mcp` (direct Convex MCP transport) |
| 122 | - `/.well-known/oauth-protected-resource` | 122 | - `/.well-known/oauth-protected-resource` |
| 123 | - `/.well-known/oauth-authorization-server` | 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 | - `/internal/runs/:runId/tool-call` | 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 | ## Configuration Reference | 132 | ## Configuration Reference |
| 130 | 133 | ||
| @@ -144,6 +147,7 @@ Important env vars (see root `.env.example` for the base template): · 13 unmodified lines | |||
| 144 | - `STRIPE_PRICE_ID` | 147 | - `STRIPE_PRICE_ID` |
| 145 | - MCP auth integration: | 148 | - MCP auth integration: |
| 146 | - `MCP_AUTHORIZATION_SERVER` or `MCP_AUTHORIZATION_SERVER_URL` | 149 | - `MCP_AUTHORIZATION_SERVER` or `MCP_AUTHORIZATION_SERVER_URL` |
| 150 | - `MCP_ENABLE_ANONYMOUS_OAUTH` (`1` to enable anonymous OAuth without external auth server) | ||
| 147 | - Managed runtime: | 151 | - Managed runtime: |
| 148 | - `EXECUTOR_RUNTIME_DIR` | 152 | - `EXECUTOR_RUNTIME_DIR` |
| 149 | - `EXECUTOR_BACKEND_PORT` | 153 | - `EXECUTOR_BACKEND_PORT` |
| @@ -29,6 +29,18 @@ export declare const api: { · 28 unmodified lines | |||
| 29 | >; | 29 | >; |
| 30 | }; | 30 | }; |
| 31 | anonymousOauth: { | 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 | getActiveSigningKey: FunctionReference< | 44 | getActiveSigningKey: FunctionReference< |
| 33 | "query", | 45 | "query", |
| 34 | "public", | 46 | "public", |
| @@ -41,6 +53,12 @@ export declare const api: { · 6 unmodified lines | |||
| 41 | { clientId: string; internalSecret: string }, | 53 | { clientId: string; internalSecret: string }, |
| 42 | any | 54 | any |
| 43 | >; | 55 | >; |
| 56 | purgeExpiredAuthorizationCodes: FunctionReference< | ||
| 57 | "mutation", | ||
| 58 | "public", | ||
| 59 | { internalSecret: string; now: number }, | ||
| 60 | any | ||
| 61 | >; | ||
| 44 | registerClient: FunctionReference< | 62 | registerClient: FunctionReference< |
| 45 | "mutation", | 63 | "mutation", |
| 46 | "public", | 64 | "public", |
| @@ -52,6 +70,23 @@ export declare const api: { · 5 unmodified lines | |||
| 52 | }, | 70 | }, |
| 53 | any | 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 | storeSigningKey: FunctionReference< | 90 | storeSigningKey: FunctionReference< |
| 56 | "mutation", | 91 | "mutation", |
| 57 | "public", | 92 | "public", |
| @@ -125,7 +160,7 @@ export declare const api: { · 67 unmodified lines | |||
| 125 | { | 160 | { |
| 126 | actorId?: string; | 161 | actorId?: string; |
| 127 | id?: string; | 162 | id?: string; |
| 128 | provider?: "managed" | "workos-vault"; | 163 | provider?: "local-convex" | "workos-vault"; |
| 129 | scope: "workspace" | "actor"; | 164 | scope: "workspace" | "actor"; |
| 130 | secretJson: any; | 165 | secretJson: any; |
| 131 | sessionId?: string; | 166 | sessionId?: string; |
| @@ -166,6 +201,12 @@ export declare const api: { · 34 unmodified lines | |||
| 166 | >; | 201 | >; |
| 167 | }; | 202 | }; |
| 168 | executorNode: { | 203 | executorNode: { |
| 204 | listToolDtsUrls: FunctionReference< | ||
| 205 | "action", | ||
| 206 | "public", | ||
| 207 | { actorId?: string; sessionId?: string; workspaceId: Id<"workspaces"> }, | ||
| 208 | any | ||
| 209 | >; | ||
| 169 | listToolsWithWarnings: FunctionReference< | 210 | listToolsWithWarnings: FunctionReference< |
| 170 | "action", | 211 | "action", |
| 171 | "public", | 212 | "public", |
| @@ -277,18 +318,6 @@ export declare const api: { · 105 unmodified lines | |||
| 277 | >; | 318 | >; |
| 278 | }; | 319 | }; |
| 279 | runtimeCallbacks: { | 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 | completeRun: FunctionReference< | 321 | completeRun: FunctionReference< |
| 293 | "mutation", | 322 | "mutation", |
| 294 | "public", | 323 | "public", |
| @@ -297,10 +326,9 @@ export declare const api: { · 2 unmodified lines | |||
| 297 | error?: string; | 326 | error?: string; |
| 298 | exitCode?: number; | 327 | exitCode?: number; |
| 299 | internalSecret: string; | 328 | internalSecret: string; |
| 329 | result?: any; | ||
| 300 | runId: string; | 330 | runId: string; |
| 301 | status: "completed" | "failed" | "timed_out" | "denied"; | 331 | status: "completed" | "failed" | "timed_out" | "denied"; |
| 302 | stderr?: string; | ||
| 303 | stdout?: string; | ||
| 304 | }, | 332 | }, |
| 305 | any | 333 | any |
| 306 | >; | 334 | >; |
| @@ -407,7 +435,8 @@ export declare const api: { · 100 unmodified lines | |||
| 407 | { | 435 | { |
| 408 | actorId?: string; | 436 | actorId?: string; |
| 409 | id?: string; | 437 | id?: string; |
| 410 | provider?: "managed" | "workos-vault"; | 438 | overridesJson?: any; |
| 439 | provider?: "local-convex" | "workos-vault"; | ||
| 411 | scope: "workspace" | "actor"; | 440 | scope: "workspace" | "actor"; |
| 412 | secretJson: any; | 441 | secretJson: any; |
| 413 | sessionId?: string; | 442 | sessionId?: string; |
| @@ -528,6 +557,18 @@ export declare const internal: { · 114 unmodified lines | |||
| 528 | { sessionId?: string }, | 557 | { sessionId?: string }, |
| 529 | any | 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 | createApproval: FunctionReference< | 572 | createApproval: FunctionReference< |
| 532 | "mutation", | 573 | "mutation", |
| 533 | "internal", | 574 | "internal", |
| @@ -567,7 +608,6 @@ export declare const internal: { · 33 unmodified lines | |||
| 567 | { | 608 | { |
| 568 | callId: string; | 609 | callId: string; |
| 569 | error?: string; | 610 | error?: string; |
| 570 | output?: any; | ||
| 571 | status: "completed" | "failed" | "denied"; | 611 | status: "completed" | "failed" | "denied"; |
| 572 | taskId: string; | 612 | taskId: string; |
| 573 | }, | 613 | }, |
| @@ -675,9 +715,8 @@ export declare const internal: { · 101 unmodified lines | |||
| 675 | { | 715 | { |
| 676 | error?: string; | 716 | error?: string; |
| 677 | exitCode?: number; | 717 | exitCode?: number; |
| 718 | result?: any; | ||
| 678 | status: "completed" | "failed" | "timed_out" | "denied"; | 719 | status: "completed" | "failed" | "timed_out" | "denied"; |
| 679 | stderr: string; | ||
| 680 | stdout: string; | ||
| 681 | taskId: string; | 720 | taskId: string; |
| 682 | }, | 721 | }, |
| 683 | any | 722 | any |
| @@ -688,6 +727,12 @@ export declare const internal: { · 4 unmodified lines | |||
| 688 | { taskId: string }, | 727 | { taskId: string }, |
| 689 | any | 728 | any |
| 690 | >; | 729 | >; |
| 730 | purgeExpiredAnonymousOauthAuthorizationCodes: FunctionReference< | ||
| 731 | "mutation", | ||
| 732 | "internal", | ||
| 733 | { now: number }, | ||
| 734 | any | ||
| 735 | >; | ||
| 691 | registerAnonymousOauthClient: FunctionReference< | 736 | registerAnonymousOauthClient: FunctionReference< |
| 692 | "mutation", | 737 | "mutation", |
| 693 | "internal", | 738 | "internal", |
| @@ -722,6 +767,22 @@ export declare const internal: { · 28 unmodified lines | |||
| 722 | { approvalId: string; callId: string; taskId: string }, | 767 | { approvalId: string; callId: string; taskId: string }, |
| 723 | any | 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 | storeAnonymousOauthSigningKey: FunctionReference< | 786 | storeAnonymousOauthSigningKey: FunctionReference< |
| 726 | "mutation", | 787 | "mutation", |
| 727 | "internal", | 788 | "internal", |
| @@ -753,7 +814,8 @@ export declare const internal: { · 25 unmodified lines | |||
| 753 | { | 814 | { |
| 754 | actorId?: string; | 815 | actorId?: string; |
| 755 | id?: string; | 816 | id?: string; |
| 756 | provider?: "managed" | "workos-vault"; | 817 | overridesJson?: any; |
| 818 | provider?: "local-convex" | "workos-vault"; | ||
| 757 | scope: "workspace" | "actor"; | 819 | scope: "workspace" | "actor"; |
| 758 | secretJson: any; | 820 | secretJson: any; |
| 759 | sourceKey: string; | 821 | sourceKey: string; |
| @@ -766,7 +828,6 @@ export declare const internal: { · 6 unmodified lines | |||
| 766 | "internal", | 828 | "internal", |
| 767 | { | 829 | { |
| 768 | callId: string; | 830 | callId: string; |
| 769 | input?: any; | ||
| 770 | taskId: string; | 831 | taskId: string; |
| 771 | toolPath: string; | 832 | toolPath: string; |
| 772 | workspaceId: Id<"workspaces">; | 833 | workspaceId: Id<"workspaces">; |
| @@ -788,17 +849,6 @@ export declare const internal: { · 15 unmodified lines | |||
| 788 | >; | 849 | >; |
| 789 | }; | 850 | }; |
| 790 | executor: { | 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 | completeRuntimeRun: FunctionReference< | 852 | completeRuntimeRun: FunctionReference< |
| 803 | "mutation", | 853 | "mutation", |
| 804 | "internal", | 854 | "internal", |
| @@ -806,10 +856,9 @@ export declare const internal: { · 1 unmodified line | |||
| 806 | durationMs?: number; | 856 | durationMs?: number; |
| 807 | error?: string; | 857 | error?: string; |
| 808 | exitCode?: number; | 858 | exitCode?: number; |
| 859 | result?: any; | ||
| 809 | runId: string; | 860 | runId: string; |
| 810 | status: "completed" | "failed" | "timed_out" | "denied"; | 861 | status: "completed" | "failed" | "timed_out" | "denied"; |
| 811 | stderr?: string; | ||
| 812 | stdout?: string; | ||
| 813 | }, | 862 | }, |
| 814 | any | 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 | import type { DataModel, Doc, Id } from "./_generated/dataModel"; | 3 | import type { DataModel, Doc, Id } from "./_generated/dataModel"; |
| 4 | import type { MutationCtx } from "./_generated/server"; | 4 | import type { MutationCtx } from "./_generated/server"; |
| 5 | import { internalMutation, mutation } from "./_generated/server"; | 5 | import { internalMutation, mutation } from "./_generated/server"; |
| 6 | import { slugify } from "../core/src/identity"; | ||
| 6 | import { ensureUniqueSlug } from "../core/src/slug"; | 7 | import { ensureUniqueSlug } from "../core/src/slug"; |
| 7 | 8 | ||
| 8 | type DbCtx = Pick<MutationCtx, "db">; | 9 | type DbCtx = Pick<MutationCtx, "db">; |
| @@ -48,14 +49,6 @@ export const authKit = · 39 unmodified lines | |||
| 48 | registerRoutes: () => {}, | 49 | registerRoutes: () => {}, |
| 49 | } as Pick<AuthKit<DataModel>, "registerRoutes">); | 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 | function titleCaseWords(input: string): string { | 52 | function titleCaseWords(input: string): string { |
| 60 | return input | 53 | return input |
| 61 | .split(" ") | 54 | .split(" ") |
| @@ -155,7 +148,7 @@ async function getFirstWorkspaceByOrganizationId(ctx: DbCtx, organizationId: Id< · 93 unmodified lines | |||
| 155 | } | 148 | } |
| 156 | 149 | ||
| 157 | async function ensureUniqueOrganizationSlug(ctx: DbCtx, baseName: string): Promise<string> { | 150 | async function ensureUniqueOrganizationSlug(ctx: DbCtx, baseName: string): Promise<string> { |
| 158 | const baseSlug = slugify(baseName); | 151 | const baseSlug = slugify(baseName, "workspace"); |
| 159 | return await ensureUniqueSlug(baseSlug, async (candidate) => { | 152 | return await ensureUniqueSlug(baseSlug, async (candidate) => { |
| 160 | const collision = await ctx.db | 153 | const collision = await ctx.db |
| 161 | .query("organizations") | 154 | .query("organizations") |
| @@ -333,7 +326,7 @@ async function ensurePersonalWorkspace( · 171 unmodified lines | |||
| 333 | updatedAt: opts.now, | 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 | const workspaceId = await ctx.db.insert("workspaces", { | 330 | const workspaceId = await ctx.db.insert("workspaces", { |
| 338 | organizationId, | 331 | organizationId, |
| 339 | slug: `${baseSlug}-${opts.workosUserId.slice(-6)}`, | 332 | slug: `${baseSlug}-${opts.workosUserId.slice(-6)}`, |
| @@ -6,7 +6,9 @@ import { internal } from "./_generated/api"; · 5 unmodified lines | |||
| 6 | import { action } from "./_generated/server"; | 6 | import { action } from "./_generated/server"; |
| 7 | 7 | ||
| 8 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | 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 | function asRecord(value: unknown): Record<string, unknown> { | 13 | function asRecord(value: unknown): Record<string, unknown> { |
| 12 | if (value && typeof value === "object" && !Array.isArray(value)) { | 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 | return actorId.trim(); | 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 | function extractObjectId(secretJson: Record<string, unknown>): string | null { | 37 | function extractObjectId(secretJson: Record<string, unknown>): string | null { |
| 25 | const candidate = | 38 | const candidate = |
| 26 | (typeof secretJson.objectId === "string" ? secretJson.objectId : "") || | 39 | (typeof secretJson.objectId === "string" ? secretJson.objectId : "") || |
| @@ -29,13 +42,24 @@ function extractObjectId(secretJson: Record<string, unknown>): string | null { · 2 unmodified lines | |||
| 29 | return trimmed.length > 0 ? trimmed : null; | 42 | return trimmed.length > 0 ? trimmed : null; |
| 30 | } | 43 | } |
| 31 | 44 | ||
| 32 | function stripObjectRef(secretJson: Record<string, unknown>): Record<string, unknown> { | 45 | function extractHeaderOverrides(secretJson: Record<string, unknown>): { |
| 33 | const { objectId: _objectId, id: _id, ...rest } = secretJson; | 46 | cleanSecret: Record<string, unknown>; |
| 34 | return asRecord(rest); | 47 | overridesJson: Record<string, unknown>; |
| 35 | } | 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 { | 58 | const { __headers: _headers, ...rest } = secretJson; |
| 38 | return Object.keys(stripObjectRef(secretJson)).length > 0; | 59 | return { |
| 60 | cleanSecret: asRecord(rest), | ||
| 61 | overridesJson: Object.keys(headers).length > 0 ? { headers } : {}, | ||
| 62 | }; | ||
| 39 | } | 63 | } |
| 40 | 64 | ||
| 41 | function buildVaultObjectName(args: { | 65 | function buildVaultObjectName(args: { |
| @@ -50,7 +74,7 @@ function buildVaultObjectName(args: { · 8 unmodified lines | |||
| 50 | .replace(/^-+|-+$/g, "") | 74 | .replace(/^-+|-+$/g, "") |
| 51 | .slice(0, 64); | 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 | function workosClient(): WorkOS { | 80 | function workosClient(): WorkOS { |
| @@ -162,19 +186,58 @@ export const upsertCredential = action({ · 105 unmodified lines | |||
| 162 | throw new Error("actorId must match the authenticated workspace actor"); | 186 | throw new Error("actorId must match the authenticated workspace actor"); |
| 163 | } | 187 | } |
| 164 | 188 | ||
| 165 | const provider = args.provider ?? "managed"; | ||
| 166 | const actorId = normalizedActorId(args.scope, args.actorId ?? canonicalActorId); | 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 | return await ctx.runMutation(internal.database.upsertCredential, { | 232 | return await ctx.runMutation(internal.database.upsertCredential, { |
| 171 | id: args.id, | 233 | id: connectionId, |
| 172 | workspaceId: args.workspaceId, | 234 | workspaceId: args.workspaceId, |
| 173 | sourceKey: args.sourceKey, | 235 | sourceKey: args.sourceKey, |
| 174 | scope: args.scope, | 236 | scope: args.scope, |
| 175 | actorId, | 237 | ...(args.scope === "actor" ? { actorId } : {}), |
| 176 | provider, | 238 | provider: "local-convex", |
| 177 | secretJson: submittedSecret, | 239 | secretJson: finalSecret, |
| 240 | overridesJson, | ||
| 178 | }); | 241 | }); |
| 179 | } | 242 | } |
| 180 | 243 | ||
| @@ -183,28 +246,19 @@ export const upsertCredential = action({ · 2 unmodified lines | |||
| 183 | throw new Error("Encrypted storage value looks like a GitHub token. Paste the token in the token field."); | 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, { | 249 | const existingObjectId = extractObjectId( |
| 187 | workspaceId: args.workspaceId, | 250 | asRecord(existingConnection?.secretJson ?? existingBinding?.secretJson), |
| 188 | sourceKey: args.sourceKey, | 251 | ); |
| 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 | })(); | ||
| 198 | 252 | ||
| 199 | let finalObjectId = objectId; | 253 | let finalObjectId = submittedObjectId; |
| 200 | if (!finalObjectId && hasPayload(submittedSecret)) { | 254 | if (!finalObjectId && Object.keys(submittedSecret).length > 0) { |
| 201 | finalObjectId = await upsertVaultObject({ | 255 | finalObjectId = await upsertVaultObject({ |
| 202 | workspaceId: args.workspaceId, | 256 | workspaceId: args.workspaceId, |
| 203 | sourceKey: args.sourceKey, | 257 | sourceKey: args.sourceKey, |
| 204 | scope: args.scope, | 258 | scope: args.scope, |
| 205 | actorId, | 259 | actorId, |
| 206 | existingObjectId, | 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 | if (!finalObjectId) { | 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 | return await ctx.runMutation(internal.database.upsertCredential, { | 273 | return await ctx.runMutation(internal.database.upsertCredential, { |
| 220 | id: args.id, | 274 | id: connectionId, |
| 221 | workspaceId: args.workspaceId, | 275 | workspaceId: args.workspaceId, |
| 222 | sourceKey: args.sourceKey, | 276 | sourceKey: args.sourceKey, |
| 223 | scope: args.scope, | 277 | scope: args.scope, |
| 224 | ...(args.scope === "actor" ? { actorId } : {}), | 278 | ...(args.scope === "actor" ? { actorId } : {}), |
| 225 | provider, | 279 | provider: "workos-vault", |
| 226 | secretJson: { objectId: finalObjectId }, | 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 | const secondRun = await t.mutation(internal.database.markTaskRunning, { taskId: "task_1" }); | 56 | const secondRun = await t.mutation(internal.database.markTaskRunning, { taskId: "task_1" }); |
| 57 | expect(secondRun).toBeNull(); | 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 | taskId: "task_1", | 60 | taskId: "task_1", |
| 61 | status: "completed", | 61 | status: "completed", |
| 62 | stdout: "ok", | 62 | result: { ok: true }, |
| 63 | stderr: "", | ||
| 64 | exitCode: 0, | 63 | exitCode: 0, |
| 65 | }); | 64 | }); |
| 66 | expect(finished?.status).toBe("completed"); | 65 | expect(finished?.status).toBe("completed"); |
| @@ -169,11 +168,11 @@ test("credentials persist provider and resolve by scope", async () => { · 102 unmodified lines | |||
| 169 | workspaceId: wsId, | 168 | workspaceId: wsId, |
| 170 | sourceKey: "openapi:github", | 169 | sourceKey: "openapi:github", |
| 171 | scope: "workspace", | 170 | scope: "workspace", |
| 172 | provider: "managed", | 171 | provider: "local-convex", |
| 173 | secretJson: { token: "workspace-token" }, | 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 | const actorCredential = await t.mutation(internal.database.upsertCredential, { | 177 | const actorCredential = await t.mutation(internal.database.upsertCredential, { |
| 179 | workspaceId: wsId, | 178 | workspaceId: wsId, |
| @@ -192,7 +191,7 @@ test("credentials persist provider and resolve by scope", async () => { · 12 unmodified lines | |||
| 192 | sourceKey: "openapi:github", | 191 | sourceKey: "openapi:github", |
| 193 | scope: "workspace", | 192 | scope: "workspace", |
| 194 | }); | 193 | }); |
| 195 | expect(resolvedWorkspace?.provider).toBe("managed"); | 194 | expect(resolvedWorkspace?.provider).toBe("local-convex"); |
| 196 | 195 | ||
| 197 | const resolvedActor = await t.query(internal.database.resolveCredential, { | 196 | const resolvedActor = await t.query(internal.database.resolveCredential, { |
| 198 | workspaceId: wsId, | 197 | workspaceId: wsId, |
| @@ -203,7 +202,7 @@ test("credentials persist provider and resolve by scope", async () => { · 4 unmodified lines | |||
| 203 | expect(resolvedActor?.provider).toBe("workos-vault"); | 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 | const t = setup(); | 206 | const t = setup(); |
| 208 | const wsId = await seedWorkspace(t, "ws_default_provider"); | 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 | secretJson: { token: "sk_test_123" }, | 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 | import type { Doc } from "./_generated/dataModel"; | 2 | import type { Doc } from "./_generated/dataModel"; |
| 3 | import { type MutationCtx, type QueryCtx } from "./_generated/server"; | 3 | import { type MutationCtx, type QueryCtx } from "./_generated/server"; |
| 4 | import { internalMutation, internalQuery } from "./_generated/server"; | 4 | import { internalMutation, internalQuery } from "./_generated/server"; |
| 5 | import { slugify } from "../core/src/identity"; | ||
| 5 | import { ensureUniqueSlug } from "../core/src/slug"; | 6 | import { ensureUniqueSlug } from "../core/src/slug"; |
| 6 | import type { TaskStatus } from "../core/src/types"; | 7 | import type { TaskStatus } from "../core/src/types"; |
| 7 | 8 | ||
| @@ -24,21 +25,13 @@ const terminalToolCallStatusValidator = v.union( · 16 unmodified lines | |||
| 24 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | 25 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 25 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | 26 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); |
| 26 | const credentialProviderValidator = v.union( | 27 | const credentialProviderValidator = v.union( |
| 27 | v.literal("managed"), | 28 | v.literal("local-convex"), |
| 28 | v.literal("workos-vault"), | 29 | v.literal("workos-vault"), |
| 29 | ); | 30 | ); |
| 30 | const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); | 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 | async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> { | 33 | async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> { |
| 41 | const baseSlug = slugify(baseName); | 34 | const baseSlug = slugify(baseName, "workspace"); |
| 42 | return await ensureUniqueSlug(baseSlug, async (candidate) => { | 35 | return await ensureUniqueSlug(baseSlug, async (candidate) => { |
| 43 | const collision = await ctx.db | 36 | const collision = await ctx.db |
| 44 | .query("organizations") | 37 | .query("organizations") |
| @@ -96,6 +89,46 @@ function asRecord(value: unknown): Record<string, unknown> { · 51 unmodified lines | |||
| 96 | return {}; | 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 | function mapTask(doc: Doc<"tasks">) { | 132 | function mapTask(doc: Doc<"tasks">) { |
| 100 | return { | 133 | return { |
| 101 | id: doc.taskId, | 134 | id: doc.taskId, |
| @@ -112,8 +145,7 @@ function mapTask(doc: Doc<"tasks">) { · 10 unmodified lines | |||
| 112 | startedAt: doc.startedAt, | 145 | startedAt: doc.startedAt, |
| 113 | completedAt: doc.completedAt, | 146 | completedAt: doc.completedAt, |
| 114 | error: doc.error, | 147 | error: doc.error, |
| 115 | stdout: doc.stdout, | 148 | result: doc.result, |
| 116 | stderr: doc.stderr, | ||
| 117 | exitCode: doc.exitCode, | 149 | exitCode: doc.exitCode, |
| 118 | }; | 150 | }; |
| 119 | } | 151 | } |
| @@ -138,10 +170,8 @@ function mapToolCall(doc: Doc<"toolCalls">) { · 18 unmodified lines | |||
| 138 | callId: doc.callId, | 170 | callId: doc.callId, |
| 139 | workspaceId: doc.workspaceId, | 171 | workspaceId: doc.workspaceId, |
| 140 | toolPath: doc.toolPath, | 172 | toolPath: doc.toolPath, |
| 141 | input: doc.input, | ||
| 142 | status: doc.status, | 173 | status: doc.status, |
| 143 | approvalId: doc.approvalId, | 174 | approvalId: doc.approvalId, |
| 144 | output: doc.output, | ||
| 145 | error: doc.error, | 175 | error: doc.error, |
| 146 | createdAt: doc.createdAt, | 176 | createdAt: doc.createdAt, |
| 147 | updatedAt: doc.updatedAt, | 177 | updatedAt: doc.updatedAt, |
| @@ -166,12 +196,15 @@ function mapPolicy(doc: Doc<"accessPolicies">) { · 18 unmodified lines | |||
| 166 | function mapCredential(doc: Doc<"sourceCredentials">) { | 196 | function mapCredential(doc: Doc<"sourceCredentials">) { |
| 167 | return { | 197 | return { |
| 168 | id: doc.credentialId, | 198 | id: doc.credentialId, |
| 199 | bindingId: doc.bindingId, | ||
| 169 | workspaceId: doc.workspaceId, | 200 | workspaceId: doc.workspaceId, |
| 170 | sourceKey: doc.sourceKey, | 201 | sourceKey: doc.sourceKey, |
| 171 | scope: doc.scope, | 202 | scope: doc.scope, |
| 172 | actorId: doc.actorId || undefined, | 203 | actorId: doc.actorId || undefined, |
| 173 | provider: doc.provider ?? "managed", | 204 | provider: doc.provider, |
| 174 | secretJson: asRecord(doc.secretJson), | 205 | secretJson: asRecord(doc.secretJson), |
| 206 | overridesJson: asRecord(doc.overridesJson), | ||
| 207 | boundAuthFingerprint: doc.boundAuthFingerprint, | ||
| 175 | createdAt: doc.createdAt, | 208 | createdAt: doc.createdAt, |
| 176 | updatedAt: doc.updatedAt, | 209 | updatedAt: doc.updatedAt, |
| 177 | }; | 210 | }; |
| @@ -184,6 +217,8 @@ function mapSource(doc: Doc<"toolSources">) { · 6 unmodified lines | |||
| 184 | name: doc.name, | 217 | name: doc.name, |
| 185 | type: doc.type, | 218 | type: doc.type, |
| 186 | config: asRecord(doc.config), | 219 | config: asRecord(doc.config), |
| 220 | specHash: doc.specHash, | ||
| 221 | authFingerprint: doc.authFingerprint, | ||
| 187 | enabled: Boolean(doc.enabled), | 222 | enabled: Boolean(doc.enabled), |
| 188 | createdAt: doc.createdAt, | 223 | createdAt: doc.createdAt, |
| 189 | updatedAt: doc.updatedAt, | 224 | updatedAt: doc.updatedAt, |
| @@ -468,8 +503,7 @@ export const markTaskFinished = internalMutation({ · 278 unmodified lines | |||
| 468 | args: { | 503 | args: { |
| 469 | taskId: v.string(), | 504 | taskId: v.string(), |
| 470 | status: completedTaskStatusValidator, | 505 | status: completedTaskStatusValidator, |
| 471 | stdout: v.string(), | 506 | result: v.optional(v.any()), |
| 472 | stderr: v.string(), | ||
| 473 | exitCode: v.optional(v.number()), | 507 | exitCode: v.optional(v.number()), |
| 474 | error: v.optional(v.string()), | 508 | error: v.optional(v.string()), |
| 475 | }, | 509 | }, |
| @@ -486,8 +520,7 @@ export const markTaskFinished = internalMutation({ · 10 unmodified lines | |||
| 486 | const now = Date.now(); | 520 | const now = Date.now(); |
| 487 | await ctx.db.patch(doc._id, { | 521 | await ctx.db.patch(doc._id, { |
| 488 | status: args.status, | 522 | status: args.status, |
| 489 | stdout: args.stdout, | 523 | result: args.result, |
| 490 | stderr: args.stderr, | ||
| 491 | exitCode: args.exitCode, | 524 | exitCode: args.exitCode, |
| 492 | error: args.error, | 525 | error: args.error, |
| 493 | completedAt: now, | 526 | completedAt: now, |
| @@ -655,7 +688,6 @@ export const upsertToolCallRequested = internalMutation({ · 161 unmodified lines | |||
| 655 | callId: v.string(), | 688 | callId: v.string(), |
| 656 | workspaceId: v.id("workspaces"), | 689 | workspaceId: v.id("workspaces"), |
| 657 | toolPath: v.string(), | 690 | toolPath: v.string(), |
| 658 | input: v.optional(v.any()), | ||
| 659 | }, | 691 | }, |
| 660 | handler: async (ctx, args) => { | 692 | handler: async (ctx, args) => { |
| 661 | const existing = await getToolCallDoc(ctx, args.taskId, args.callId); | 693 | const existing = await getToolCallDoc(ctx, args.taskId, args.callId); |
| @@ -669,7 +701,6 @@ export const upsertToolCallRequested = internalMutation({ · 7 unmodified lines | |||
| 669 | callId: args.callId, | 701 | callId: args.callId, |
| 670 | workspaceId: args.workspaceId, | 702 | workspaceId: args.workspaceId, |
| 671 | toolPath: args.toolPath, | 703 | toolPath: args.toolPath, |
| 672 | input: args.input ?? {}, | ||
| 673 | status: "requested", | 704 | status: "requested", |
| 674 | createdAt: now, | 705 | createdAt: now, |
| 675 | updatedAt: now, | 706 | updatedAt: now, |
| @@ -730,7 +761,6 @@ export const finishToolCall = internalMutation({ · 54 unmodified lines | |||
| 730 | taskId: v.string(), | 761 | taskId: v.string(), |
| 731 | callId: v.string(), | 762 | callId: v.string(), |
| 732 | status: terminalToolCallStatusValidator, | 763 | status: terminalToolCallStatusValidator, |
| 733 | output: v.optional(v.any()), | ||
| 734 | error: v.optional(v.string()), | 764 | error: v.optional(v.string()), |
| 735 | }, | 765 | }, |
| 736 | handler: async (ctx, args) => { | 766 | handler: async (ctx, args) => { |
| @@ -746,7 +776,6 @@ export const finishToolCall = internalMutation({ · 9 unmodified lines | |||
| 746 | const now = Date.now(); | 776 | const now = Date.now(); |
| 747 | await ctx.db.patch(doc._id, { | 777 | await ctx.db.patch(doc._id, { |
| 748 | status: args.status, | 778 | status: args.status, |
| 749 | output: args.output, | ||
| 750 | error: args.error, | 779 | error: args.error, |
| 751 | updatedAt: now, | 780 | updatedAt: now, |
| 752 | completedAt: now, | 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 | export const upsertCredential = internalMutation({ | 983 | export const upsertCredential = internalMutation({ |
| 928 | args: { | 984 | args: { |
| 929 | id: v.optional(v.string()), | 985 | id: v.optional(v.string()), |
| @@ -933,10 +989,13 @@ export const upsertCredential = internalMutation({ · 3 unmodified lines | |||
| 933 | actorId: v.optional(v.string()), | 989 | actorId: v.optional(v.string()), |
| 934 | provider: v.optional(credentialProviderValidator), | 990 | provider: v.optional(credentialProviderValidator), |
| 935 | secretJson: v.any(), | 991 | secretJson: v.any(), |
| 992 | overridesJson: v.optional(v.any()), | ||
| 936 | }, | 993 | }, |
| 937 | handler: async (ctx, args) => { | 994 | handler: async (ctx, args) => { |
| 938 | const now = Date.now(); | 995 | const now = Date.now(); |
| 939 | const actorId = args.scope === "actor" ? (args.actorId?.trim() || "") : ""; | 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 | const existing = await ctx.db | 1000 | const existing = await ctx.db |
| 942 | .query("sourceCredentials") | 1001 | .query("sourceCredentials") |
| @@ -949,23 +1008,71 @@ export const upsertCredential = internalMutation({ · 6 unmodified lines | |||
| 949 | ) | 1008 | ) |
| 950 | .unique(); | 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 | if (existing) { | 1055 | if (existing) { |
| 955 | await ctx.db.patch(existing._id, { | 1056 | await ctx.db.patch(existing._id, { |
| 1057 | credentialId: connectionId, | ||
| 956 | provider, | 1058 | provider, |
| 957 | secretJson: asRecord(args.secretJson), | 1059 | secretJson: finalSecret, |
| 1060 | overridesJson, | ||
| 1061 | boundAuthFingerprint, | ||
| 958 | updatedAt: now, | 1062 | updatedAt: now, |
| 959 | }); | 1063 | }); |
| 960 | } else { | 1064 | } else { |
| 961 | await ctx.db.insert("sourceCredentials", { | 1065 | await ctx.db.insert("sourceCredentials", { |
| 962 | credentialId: args.id ?? `cred_${crypto.randomUUID()}`, | 1066 | bindingId: `bind_${crypto.randomUUID()}`, |
| 1067 | credentialId: connectionId, | ||
| 963 | workspaceId: args.workspaceId, | 1068 | workspaceId: args.workspaceId, |
| 964 | sourceKey: args.sourceKey, | 1069 | sourceKey: args.sourceKey, |
| 965 | scope: args.scope, | 1070 | scope: args.scope, |
| 966 | actorId, | 1071 | actorId, |
| 967 | provider, | 1072 | provider, |
| 968 | secretJson: asRecord(args.secretJson), | 1073 | secretJson: finalSecret, |
| 1074 | overridesJson, | ||
| 1075 | boundAuthFingerprint, | ||
| 969 | createdAt: now, | 1076 | createdAt: now, |
| 970 | updatedAt: now, | 1077 | updatedAt: now, |
| 971 | }); | 1078 | }); |
| @@ -1005,16 +1112,14 @@ export const listCredentials = internalQuery({ · 33 unmodified lines | |||
| 1005 | export const listCredentialProviders = internalQuery({ | 1112 | export const listCredentialProviders = internalQuery({ |
| 1006 | args: {}, | 1113 | args: {}, |
| 1007 | handler: async () => { | 1114 | handler: async () => { |
| 1115 | const workosEnabled = Boolean(process.env.WORKOS_API_KEY?.trim()); | ||
| 1008 | return [ | 1116 | return [ |
| 1009 | { | 1117 | { |
| 1010 | id: "managed", | 1118 | id: workosEnabled ? "workos-vault" : "local-convex", |
| 1011 | label: "Managed", | 1119 | label: workosEnabled ? "Encrypted" : "Local", |
| 1012 | description: "Store credential payload in Executor's sourceCredentials table.", | 1120 | description: workosEnabled |
| 1013 | }, | 1121 | ? "Secrets are stored in WorkOS Vault." |
| 1014 | { | 1122 | : "Secrets are stored locally in Convex on this machine.", |
| 1015 | id: "workos-vault", | ||
| 1016 | label: "Encrypted", | ||
| 1017 | description: "Store credential payload in encrypted external storage.", | ||
| 1018 | }, | 1123 | }, |
| 1019 | ] as const; | 1124 | ] as const; |
| 1020 | }, | 1125 | }, |
| @@ -1075,6 +1180,9 @@ export const upsertToolSource = internalMutation({ · 54 unmodified lines | |||
| 1075 | handler: async (ctx, args) => { | 1180 | handler: async (ctx, args) => { |
| 1076 | const now = Date.now(); | 1181 | const now = Date.now(); |
| 1077 | const sourceId = args.id ?? `src_${crypto.randomUUID()}`; | 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 | const [existing, conflict] = await Promise.all([ | 1186 | const [existing, conflict] = await Promise.all([ |
| 1079 | ctx.db | 1187 | ctx.db |
| 1080 | .query("toolSources") | 1188 | .query("toolSources") |
| @@ -1095,7 +1203,9 @@ export const upsertToolSource = internalMutation({ · 14 unmodified lines | |||
| 1095 | workspaceId: args.workspaceId, | 1203 | workspaceId: args.workspaceId, |
| 1096 | name: args.name, | 1204 | name: args.name, |
| 1097 | type: args.type, | 1205 | type: args.type, |
| 1098 | config: asRecord(args.config), | 1206 | config, |
| 1207 | specHash, | ||
| 1208 | authFingerprint, | ||
| 1099 | enabled: args.enabled !== false, | 1209 | enabled: args.enabled !== false, |
| 1100 | updatedAt: now, | 1210 | updatedAt: now, |
| 1101 | }); | 1211 | }); |
| @@ -1105,7 +1215,9 @@ export const upsertToolSource = internalMutation({ · 3 unmodified lines | |||
| 1105 | workspaceId: args.workspaceId, | 1215 | workspaceId: args.workspaceId, |
| 1106 | name: args.name, | 1216 | name: args.name, |
| 1107 | type: args.type, | 1217 | type: args.type, |
| 1108 | config: asRecord(args.config), | 1218 | config, |
| 1219 | specHash, | ||
| 1220 | authFingerprint, | ||
| 1109 | enabled: args.enabled !== false, | 1221 | enabled: args.enabled !== false, |
| 1110 | createdAt: now, | 1222 | createdAt: now, |
| 1111 | updatedAt: now, | 1223 | updatedAt: now, |
| @@ -1147,6 +1259,18 @@ export const deleteToolSource = internalMutation({ · 35 unmodified lines | |||
| 1147 | return false; | 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 | await ctx.db.delete(doc._id); | 1274 | await ctx.db.delete(doc._id); |
| 1151 | return true; | 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 | export const completeRuntimeRun = internalMutation({ | 225 | export const completeRuntimeRun = internalMutation({ |
| 249 | args: { | 226 | args: { |
| 250 | runId: v.string(), | 227 | runId: v.string(), |
| 251 | status: v.union(v.literal("completed"), v.literal("failed"), v.literal("timed_out"), v.literal("denied")), | 228 | status: v.union(v.literal("completed"), v.literal("failed"), v.literal("timed_out"), v.literal("denied")), |
| 252 | stdout: v.optional(v.string()), | 229 | result: v.optional(v.any()), |
| 253 | stderr: v.optional(v.string()), | ||
| 254 | exitCode: v.optional(v.number()), | 230 | exitCode: v.optional(v.number()), |
| 255 | error: v.optional(v.string()), | 231 | error: v.optional(v.string()), |
| 256 | durationMs: v.optional(v.number()), | 232 | durationMs: v.optional(v.number()), |
| @@ -265,11 +241,10 @@ export const completeRuntimeRun = internalMutation({ · 8 unmodified lines | |||
| 265 | return { ok: true as const, alreadyFinal: true as const, task }; | 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 | taskId: args.runId, | 245 | taskId: args.runId, |
| 270 | status: args.status, | 246 | status: args.status, |
| 271 | stdout: args.stdout ?? "", | 247 | result: args.result, |
| 272 | stderr: args.stderr ?? "", | ||
| 273 | exitCode: args.exitCode, | 248 | exitCode: args.exitCode, |
| 274 | error: args.error, | 249 | error: args.error, |
| 275 | }); | 250 | }); |
| @@ -1,1062 +1,22 @@ | |||
| 1 | "use node"; | 1 | "use node"; |
| 2 | 2 | ||
| 3 | import { v } from "convex/values"; | 3 | import { v } from "convex/values"; |
| 4 | import { internal } from "./_generated/api"; | ||
| 5 | import type { Id } from "./_generated/dataModel"; | ||
| 6 | import { action, internalAction } from "./_generated/server"; | 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 | import type { | 5 | import type { |
| 38 | AccessPolicyRecord, | ||
| 39 | CredentialScope, | ||
| 40 | PolicyDecision, | ||
| 41 | ResolvedToolCredential, | ||
| 42 | TaskRecord, | ||
| 43 | ToolCallRequest, | ||
| 44 | ToolCallRecord, | ||
| 45 | ToolCallResult, | 6 | ToolCallResult, |
| 46 | ToolCredentialSpec, | ||
| 47 | ToolDefinition, | ||
| 48 | ToolDescriptor, | 7 | ToolDescriptor, |
| 49 | OpenApiSourceQuality, | 8 | OpenApiSourceQuality, |
| 50 | ToolSourceRecord, | 9 | SourceAuthProfile, |
| 51 | ToolRunContext, | ||
| 52 | } from "../core/src/types"; | 10 | } from "../core/src/types"; |
| 53 | import { asPayload, describeError } from "../core/src/utils"; | 11 | import { requireCanonicalActor } from "./executor_node/actor_auth"; |
| 54 | 12 | import { | |
| 55 | function createApprovalId(): string { | 13 | listToolsForContext, |
| 56 | return `approval_${crypto.randomUUID()}`; | 14 | listToolsWithWarningsForContext, |
| 57 | } | 15 | loadDtsUrls, |
| 58 | 16 | loadWorkspaceDtsStorageIds, | |
| 59 | function matchesToolPath(pattern: string, toolPath: string): boolean { | 17 | } from "./executor_node/workspace_tools"; |
| 60 | const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); | 18 | import { runQueuedTask } from "./executor_node/task_runner"; |
| 61 | const regex = new RegExp(`^${escaped}$`); | 19 | import { handleExternalToolCallRequest } from "./executor_node/external_tool_call"; |
| 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 | } | ||
| 1060 | 20 | ||
| 1061 | export const listToolsWithWarnings = action({ | 21 | export const listToolsWithWarnings = action({ |
| 1062 | args: { | 22 | args: { |
| @@ -1073,19 +33,13 @@ export const listToolsWithWarnings = action({ · 10 unmodified lines | |||
| 1073 | warnings: string[]; | 33 | warnings: string[]; |
| 1074 | dtsUrls: Record<string, string>; | 34 | dtsUrls: Record<string, string>; |
| 1075 | sourceQuality: Record<string, OpenApiSourceQuality>; | 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 | workspaceId: args.workspaceId, | 39 | workspaceId: args.workspaceId, |
| 1079 | sessionId: args.sessionId, | 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 | return await listToolsWithWarningsForContext(ctx, { | 44 | return await listToolsWithWarningsForContext(ctx, { |
| 1091 | workspaceId: args.workspaceId, | 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 | export const listToolsInternal = internalAction({ | 75 | export const listToolsInternal = internalAction({ |
| 1099 | args: { | 76 | args: { |
| 1100 | workspaceId: v.id("workspaces"), | 77 | workspaceId: v.id("workspaces"), |
| @@ -1120,6 +97,7 @@ export const listToolsWithWarningsInternal = internalAction({ · 19 unmodified lines | |||
| 1120 | warnings: string[]; | 97 | warnings: string[]; |
| 1121 | dtsUrls: Record<string, string>; | 98 | dtsUrls: Record<string, string>; |
| 1122 | sourceQuality: Record<string, OpenApiSourceQuality>; | 99 | sourceQuality: Record<string, OpenApiSourceQuality>; |
| 100 | sourceAuthProfiles: Record<string, SourceAuthProfile>; | ||
| 1123 | }> => { | 101 | }> => { |
| 1124 | return await listToolsWithWarningsForContext(ctx, args); | 102 | return await listToolsWithWarningsForContext(ctx, args); |
| 1125 | }, | 103 | }, |
| @@ -1132,226 +110,10 @@ export const handleExternalToolCall = internalAction({ · 6 unmodified lines | |||
| 1132 | toolPath: v.string(), | 110 | toolPath: v.string(), |
| 1133 | input: v.optional(v.any()), | 111 | input: v.optional(v.any()), |
| 1134 | }, | 112 | }, |
| 1135 | handler: async (ctx, args): Promise<ToolCallResult> => { | 113 | handler: async (ctx, args): Promise<ToolCallResult> => await handleExternalToolCallRequest(ctx, args), |
| 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 | }, | ||
| 1182 | }); | 114 | }); |
| 1183 | 115 | ||
| 1184 | export const runTask = internalAction({ | 116 | export const runTask = internalAction({ |
| 1185 | args: { taskId: v.string() }, | 117 | args: { taskId: v.string() }, |
| 1186 | handler: async (ctx, args) => { | 118 | handler: async (ctx, args) => await runQueuedTask(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 | }, | ||
| 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 | import { registerRoutes as registerStripeRoutes } from "@convex-dev/stripe"; | 1 | import { registerRoutes as registerStripeRoutes } from "@convex-dev/stripe"; |
| 2 | import { createRemoteJWKSet, jwtVerify } from "jose"; | ||
| 3 | import { httpRouter } from "convex/server"; | 2 | import { httpRouter } from "convex/server"; |
| 4 | import { components, internal } from "./_generated/api"; | 3 | import { components } from "./_generated/api"; |
| 5 | import { httpAction } from "./_generated/server"; | ||
| 6 | import { authKit } from "./auth"; | 4 | import { authKit } from "./auth"; |
| 7 | import { handleMcpRequest, type McpWorkspaceContext } from "../core/src/mcp-server"; | 5 | import { mcpHandler } from "./http/mcp_handler"; |
| 8 | import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescriptor } from "../core/src/types"; | 6 | import { |
| 9 | import type { Id } from "./_generated/dataModel"; | 7 | oauthAuthorizationServerHandler, |
| 8 | oauthAuthorizeHandler, | ||
| 9 | oauthJwksHandler, | ||
| 10 | oauthProtectedResourceHandler, | ||
| 11 | oauthRegisterHandler, | ||
| 12 | oauthTokenHandler, | ||
| 13 | } from "./http/oauth_handlers"; | ||
| 10 | 14 | ||
| 11 | const http = httpRouter(); | 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 | authKit.registerRoutes(http); | 17 | authKit.registerRoutes(http); |
| 294 | registerStripeRoutes(http, components.stripe, { | 18 | registerStripeRoutes(http, components.stripe, { |
| 295 | webhookPath: "/stripe/webhook", | 19 | webhookPath: "/stripe/webhook", |
| @@ -298,7 +22,12 @@ registerStripeRoutes(http, components.stripe, { · 2 unmodified lines | |||
| 298 | http.route({ path: "/mcp", method: "POST", handler: mcpHandler }); | 22 | http.route({ path: "/mcp", method: "POST", handler: mcpHandler }); |
| 299 | http.route({ path: "/mcp", method: "GET", handler: mcpHandler }); | 23 | http.route({ path: "/mcp", method: "GET", handler: mcpHandler }); |
| 300 | http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler }); | 24 | http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler }); |
| 25 | |||
| 301 | http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler }); | 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 | export default http; | 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 | import { v } from "convex/values"; | 3 | import { v } from "convex/values"; |
| 4 | import { internal } from "./_generated/api"; | 4 | import { internal } from "./_generated/api"; |
| 5 | import { internalAction } from "./_generated/server"; | 5 | import { internalAction } from "./_generated/server"; |
| 6 | import { loadSourceDtsByUrlCached } from "../core/src/dts-loader"; | 6 | import { loadSourceDtsByUrl } from "../core/src/dts-loader"; |
| 7 | import { generateToolDeclarations, typecheckCode } from "../core/src/typechecker"; | 7 | import { generateToolDeclarations, typecheckCode } from "../core/src/typechecker"; |
| 8 | import type { ToolDescriptor } from "../core/src/types"; | 8 | import type { ToolDescriptor } from "../core/src/types"; |
| 9 | 9 | ||
| @@ -26,7 +26,7 @@ export const typecheckRunCodeInternal = internalAction({ · 16 unmodified lines | |||
| 26 | dtsUrls?: Record<string, string>; | 26 | dtsUrls?: Record<string, string>; |
| 27 | }; | 27 | }; |
| 28 | 28 | ||
| 29 | const sourceDtsBySource = await loadSourceDtsByUrlCached(result.dtsUrls ?? {}); | 29 | const sourceDtsBySource = await loadSourceDtsByUrl(result.dtsUrls ?? {}); |
| 30 | const declarations = generateToolDeclarations(result.tools, { | 30 | const declarations = generateToolDeclarations(result.tools, { |
| 31 | sourceDtsBySource, | 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 | export const completeRun = mutation({ | 35 | export const completeRun = mutation({ |
| 65 | args: { | 36 | args: { |
| 66 | internalSecret: v.string(), | 37 | internalSecret: v.string(), |
| 67 | runId: v.string(), | 38 | runId: v.string(), |
| 68 | status: v.union(v.literal("completed"), v.literal("failed"), v.literal("timed_out"), v.literal("denied")), | 39 | status: v.union(v.literal("completed"), v.literal("failed"), v.literal("timed_out"), v.literal("denied")), |
| 69 | stdout: v.optional(v.string()), | 40 | result: v.optional(v.any()), |
| 70 | stderr: v.optional(v.string()), | ||
| 71 | exitCode: v.optional(v.number()), | 41 | exitCode: v.optional(v.number()), |
| 72 | error: v.optional(v.string()), | 42 | error: v.optional(v.string()), |
| 73 | durationMs: v.optional(v.number()), | 43 | durationMs: v.optional(v.number()), |
| @@ -75,11 +45,10 @@ export const completeRun = mutation({ · 1 unmodified line | |||
| 75 | handler: async (ctx, args) => { | 45 | handler: async (ctx, args) => { |
| 76 | requireInternalSecret(args.internalSecret); | 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 | runId: args.runId, | 49 | runId: args.runId, |
| 80 | status: args.status, | 50 | status: args.status, |
| 81 | stdout: args.stdout, | 51 | result: args.result, |
| 82 | stderr: args.stderr, | ||
| 83 | exitCode: args.exitCode, | 52 | exitCode: args.exitCode, |
| 84 | error: args.error, | 53 | error: args.error, |
| 85 | durationMs: args.durationMs, | 54 | durationMs: args.durationMs, |
| @@ -44,7 +44,7 @@ const toolCallStatus = v.union( · 43 unmodified lines | |||
| 44 | const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | 44 | const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 45 | const credentialScope = v.union(v.literal("workspace"), v.literal("actor")); | 45 | const credentialScope = v.union(v.literal("workspace"), v.literal("actor")); |
| 46 | const credentialProvider = v.union( | 46 | const credentialProvider = v.union( |
| 47 | v.literal("managed"), | 47 | v.literal("local-convex"), |
| 48 | v.literal("workos-vault"), | 48 | v.literal("workos-vault"), |
| 49 | ); | 49 | ); |
| 50 | const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); | 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 | timeoutMs: v.number(), | 186 | timeoutMs: v.number(), |
| 187 | metadata: v.any(), | 187 | metadata: v.any(), |
| 188 | error: v.optional(v.string()), | 188 | error: v.optional(v.string()), |
| 189 | stdout: v.optional(v.string()), | 189 | result: v.optional(v.any()), |
| 190 | stderr: v.optional(v.string()), | ||
| 191 | exitCode: v.optional(v.number()), | 190 | exitCode: v.optional(v.number()), |
| 192 | createdAt: v.number(), | 191 | createdAt: v.number(), |
| 193 | updatedAt: v.number(), | 192 | updatedAt: v.number(), |
| @@ -219,10 +218,8 @@ export default defineSchema({ · 25 unmodified lines | |||
| 219 | callId: v.string(), | 218 | callId: v.string(), |
| 220 | workspaceId: v.id("workspaces"), | 219 | workspaceId: v.id("workspaces"), |
| 221 | toolPath: v.string(), | 220 | toolPath: v.string(), |
| 222 | input: v.any(), | ||
| 223 | status: toolCallStatus, | 221 | status: toolCallStatus, |
| 224 | approvalId: v.optional(v.string()), | 222 | approvalId: v.optional(v.string()), |
| 225 | output: v.optional(v.any()), | ||
| 226 | error: v.optional(v.string()), | 223 | error: v.optional(v.string()), |
| 227 | createdAt: v.number(), | 224 | createdAt: v.number(), |
| 228 | updatedAt: v.number(), | 225 | updatedAt: v.number(), |
| @@ -258,18 +255,23 @@ export default defineSchema({ · 29 unmodified lines | |||
| 258 | .index("by_workspace_created", ["workspaceId", "createdAt"]), | 255 | .index("by_workspace_created", ["workspaceId", "createdAt"]), |
| 259 | 256 | ||
| 260 | sourceCredentials: defineTable({ | 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 | workspaceId: v.id("workspaces"), | 260 | workspaceId: v.id("workspaces"), |
| 263 | sourceKey: v.string(), | 261 | sourceKey: v.string(), |
| 264 | scope: credentialScope, | 262 | scope: credentialScope, |
| 265 | actorId: v.string(), // account._id or anon_<uuid> (required for composite index) | 263 | actorId: v.string(), // account._id or anon_<uuid> (required for composite index) |
| 266 | provider: credentialProvider, | 264 | provider: credentialProvider, |
| 267 | secretJson: v.any(), | 265 | secretJson: v.any(), |
| 266 | overridesJson: v.optional(v.any()), | ||
| 267 | boundAuthFingerprint: v.optional(v.string()), | ||
| 268 | createdAt: v.number(), | 268 | createdAt: v.number(), |
| 269 | updatedAt: v.number(), | 269 | updatedAt: v.number(), |
| 270 | }) | 270 | }) |
| 271 | .index("by_workspace_created", ["workspaceId", "createdAt"]) | 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 | toolSources: defineTable({ | 276 | toolSources: defineTable({ |
| 275 | sourceId: v.string(), // domain ID: src_<uuid> | 277 | sourceId: v.string(), // domain ID: src_<uuid> |
| @@ -277,6 +279,8 @@ export default defineSchema({ · 1 unmodified line | |||
| 277 | name: v.string(), | 279 | name: v.string(), |
| 278 | type: toolSourceType, | 280 | type: toolSourceType, |
| 279 | config: v.any(), | 281 | config: v.any(), |
| 282 | specHash: v.optional(v.string()), | ||
| 283 | authFingerprint: v.optional(v.string()), | ||
| 280 | enabled: v.boolean(), | 284 | enabled: v.boolean(), |
| 281 | createdAt: v.number(), | 285 | createdAt: v.number(), |
| 282 | updatedAt: v.number(), | 286 | updatedAt: v.number(), |
| @@ -355,4 +359,22 @@ export default defineSchema({ · 72 unmodified lines | |||
| 355 | createdAt: v.number(), | 359 | createdAt: v.number(), |
| 356 | }) | 360 | }) |
| 357 | .index("by_client_id", ["clientId"]), | 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 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | 6 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 7 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | 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 | const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); | 9 | const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql")); |
| 10 | 10 | ||
| 11 | function redactCredential<T extends { secretJson: Record<string, unknown> }>(credential: T): T { | 11 | function redactCredential<T extends { secretJson: Record<string, unknown> }>(credential: T): T { |
| @@ -110,6 +110,7 @@ export const upsertCredential = workspaceMutation({ · 98 unmodified lines | |||
| 110 | actorId: v.optional(v.string()), | 110 | actorId: v.optional(v.string()), |
| 111 | provider: v.optional(credentialProviderValidator), | 111 | provider: v.optional(credentialProviderValidator), |
| 112 | secretJson: v.any(), | 112 | secretJson: v.any(), |
| 113 | overridesJson: v.optional(v.any()), | ||
| 113 | }, | 114 | }, |
| 114 | handler: async (ctx, args) => { | 115 | handler: async (ctx, args) => { |
| 115 | return await ctx.runMutation(internal.database.upsertCredential, { | 116 | return await ctx.runMutation(internal.database.upsertCredential, { |
| @@ -10,7 +10,6 @@ test("returns run mismatch without invoking tool", async () => { · 9 unmodified lines | |||
| 10 | called += 1; | 10 | called += 1; |
| 11 | return { ok: true }; | 11 | return { ok: true }; |
| 12 | }, | 12 | }, |
| 13 | emitOutput: () => {}, | ||
| 14 | }); | 13 | }); |
| 15 | 14 | ||
| 16 | const result = await adapter.invokeTool({ | 15 | const result = await adapter.invokeTool({ |
| @@ -33,7 +32,6 @@ test("maps approval denied errors to denied result", async () => { · 16 unmodified lines | |||
| 33 | invokeTool: async () => { | 32 | invokeTool: async () => { |
| 34 | throw new Error(`${APPROVAL_DENIED_PREFIX}approval required`); | 33 | throw new Error(`${APPROVAL_DENIED_PREFIX}approval required`); |
| 35 | }, | 34 | }, |
| 36 | emitOutput: () => {}, | ||
| 37 | }); | 35 | }); |
| 38 | 36 | ||
| 39 | const result = await adapter.invokeTool({ | 37 | const result = await adapter.invokeTool({ |
| @@ -49,30 +47,3 @@ test("maps approval denied errors to denied result", async () => { · 9 unmodified lines | |||
| 49 | error: "approval required", | 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 | import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../execution-constants"; | 1 | import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../execution-constants"; |
| 2 | import type { | 2 | import type { |
| 3 | ExecutionAdapter, | 3 | ExecutionAdapter, |
| 4 | RuntimeOutputEvent, | ||
| 5 | ToolCallRequest, | 4 | ToolCallRequest, |
| 6 | ToolCallResult, | 5 | ToolCallResult, |
| 7 | } from "../types"; | 6 | } from "../types"; |
| @@ -10,7 +9,6 @@ import { describeError } from "../utils"; · 2 unmodified lines | |||
| 10 | interface InProcessExecutionAdapterOptions { | 9 | interface InProcessExecutionAdapterOptions { |
| 11 | runId: string; | 10 | runId: string; |
| 12 | invokeTool: (call: ToolCallRequest) => Promise<unknown>; | 11 | invokeTool: (call: ToolCallRequest) => Promise<unknown>; |
| 13 | emitOutput: (event: RuntimeOutputEvent) => void; | ||
| 14 | } | 12 | } |
| 15 | 13 | ||
| 16 | export class InProcessExecutionAdapter implements ExecutionAdapter { | 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 | function createServer(issuer = "http://localhost:3003"): AnonymousOAuthServer { | 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 | /** Generate a random PKCE code_verifier (43–128 chars, unreserved). */ | 17 | /** Generate a random PKCE code_verifier (43–128 chars, unreserved). */ |
| @@ -253,6 +253,85 @@ describe("AnonymousOAuthServer", () => { · 235 unmodified lines | |||
| 253 | expect(verified!.provider).toBe("anonymous"); | 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 | test("code is single-use", async () => { | 335 | test("code is single-use", async () => { |
| 257 | const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(server); | 336 | const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(server); |
| 258 | 337 | ||
| @@ -298,6 +377,7 @@ describe("AnonymousOAuthServer", () => { · 39 unmodified lines | |||
| 298 | const shortServer = new AnonymousOAuthServer({ | 377 | const shortServer = new AnonymousOAuthServer({ |
| 299 | issuer: "http://localhost:3003", | 378 | issuer: "http://localhost:3003", |
| 300 | codeExpirySeconds: 0, // expires immediately | 379 | codeExpirySeconds: 0, // expires immediately |
| 380 | storage: new InMemoryOAuthStorage(), | ||
| 301 | }); | 381 | }); |
| 302 | await shortServer.init(); | 382 | await shortServer.init(); |
| 303 | 383 | ||
| @@ -433,20 +513,21 @@ describe("AnonymousOAuthServer", () => { · 129 unmodified lines | |||
| 433 | const s = new AnonymousOAuthServer({ | 513 | const s = new AnonymousOAuthServer({ |
| 434 | issuer: "http://localhost:3003", | 514 | issuer: "http://localhost:3003", |
| 435 | codeExpirySeconds: 0, | 515 | codeExpirySeconds: 0, |
| 516 | storage: new InMemoryOAuthStorage(), | ||
| 436 | }); | 517 | }); |
| 437 | await s.init(); | 518 | await s.init(); |
| 438 | 519 | ||
| 439 | // Generate some codes | 520 | // Generate some codes |
| 440 | await registerAndAuthorize(s); | 521 | await registerAndAuthorize(s); |
| 441 | await registerAndAuthorize(s); | 522 | await registerAndAuthorize(s); |
| 442 | expect(s.getCodeCount()).toBe(2); | 523 | expect(await s.getCodeCount()).toBe(2); |
| 443 | 524 | ||
| 444 | // Wait for them to expire | 525 | // Wait for them to expire |
| 445 | await new Promise((resolve) => setTimeout(resolve, 10)); | 526 | await new Promise((resolve) => setTimeout(resolve, 10)); |
| 446 | 527 | ||
| 447 | const purged = s.purgeExpiredCodes(); | 528 | const purged = await s.purgeExpiredCodes(); |
| 448 | expect(purged).toBe(2); | 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 | issuer: "http://localhost:3003", | 539 | issuer: "http://localhost:3003", |
| 459 | maxPendingCodes: 2, | 540 | maxPendingCodes: 2, |
| 460 | codeExpirySeconds: 300, // long-lived so they don't auto-purge | 541 | codeExpirySeconds: 300, // long-lived so they don't auto-purge |
| 542 | storage: new InMemoryOAuthStorage(), | ||
| 461 | }); | 543 | }); |
| 462 | await s.init(); | 544 | await s.init(); |
| 463 | 545 | ||
| 464 | // Fill up the code slots | 546 | // Fill up the code slots |
| 465 | await registerAndAuthorize(s); | 547 | await registerAndAuthorize(s); |
| 466 | await registerAndAuthorize(s); | 548 | await registerAndAuthorize(s); |
| 467 | expect(s.getCodeCount()).toBe(2); | 549 | expect(await s.getCodeCount()).toBe(2); |
| 468 | 550 | ||
| 469 | // Third should be rejected | 551 | // Third should be rejected |
| 470 | await expect(registerAndAuthorize(s)).rejects.toThrow( | 552 | await expect(registerAndAuthorize(s)).rejects.toThrow( |
| @@ -477,13 +559,14 @@ describe("AnonymousOAuthServer", () => { · 6 unmodified lines | |||
| 477 | issuer: "http://localhost:3003", | 559 | issuer: "http://localhost:3003", |
| 478 | maxPendingCodes: 2, | 560 | maxPendingCodes: 2, |
| 479 | codeExpirySeconds: 0, // codes expire immediately | 561 | codeExpirySeconds: 0, // codes expire immediately |
| 562 | storage: new InMemoryOAuthStorage(), | ||
| 480 | }); | 563 | }); |
| 481 | await s.init(); | 564 | await s.init(); |
| 482 | 565 | ||
| 483 | // Fill slots (they expire immediately) | 566 | // Fill slots (they expire immediately) |
| 484 | await registerAndAuthorize(s); | 567 | await registerAndAuthorize(s); |
| 485 | await registerAndAuthorize(s); | 568 | await registerAndAuthorize(s); |
| 486 | expect(s.getCodeCount()).toBe(2); | 569 | expect(await s.getCodeCount()).toBe(2); |
| 487 | 570 | ||
| 488 | // Wait for expiry | 571 | // Wait for expiry |
| 489 | await new Promise((resolve) => setTimeout(resolve, 10)); | 572 | await new Promise((resolve) => setTimeout(resolve, 10)); |
| @@ -21,10 +21,8 @@ · 20 unmodified lines | |||
| 21 | * | 21 | * |
| 22 | * Storage is pluggable via the `OAuthStorage` interface: | 22 | * Storage is pluggable via the `OAuthStorage` interface: |
| 23 | * - `InMemoryOAuthStorage` — ephemeral, for tests and single-process dev | 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 | * so tokens survive gateway restarts | 25 | * so tokens survive gateway restarts |
| 26 | * | ||
| 27 | * Authorization codes are always in-memory (short-lived, single-use). | ||
| 28 | */ | 26 | */ |
| 29 | 27 | ||
| 30 | import { | 28 | import { |
| @@ -36,7 +34,6 @@ import { · 5 unmodified lines | |||
| 36 | jwtVerify, | 34 | jwtVerify, |
| 37 | type JWK, | 35 | type JWK, |
| 38 | type CryptoKey as JoseCryptoKey, | 36 | type CryptoKey as JoseCryptoKey, |
| 39 | type JWTVerifyGetKey, | ||
| 40 | } from "jose"; | 37 | } from "jose"; |
| 41 | 38 | ||
| 42 | // --------------------------------------------------------------------------- | 39 | // --------------------------------------------------------------------------- |
| @@ -57,7 +54,39 @@ interface AuthorizationCode { · 14 unmodified lines | |||
| 57 | codeChallenge: string; | 54 | codeChallenge: string; |
| 58 | codeChallengeMethod: string; | 55 | codeChallengeMethod: string; |
| 59 | actorId: string; | 56 | actorId: string; |
| 57 | tokenClaims?: Record<string, unknown>; | ||
| 60 | expiresAt: number; | 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 | export interface AnonOAuthConfig { | 92 | export interface AnonOAuthConfig { |
| @@ -69,8 +98,8 @@ export interface AnonOAuthConfig { · 5 unmodified lines | |||
| 69 | codeExpirySeconds?: number; | 98 | codeExpirySeconds?: number; |
| 70 | /** Maximum number of in-flight authorization codes. Default 10 000. */ | 99 | /** Maximum number of in-flight authorization codes. Default 10 000. */ |
| 71 | maxPendingCodes?: number; | 100 | maxPendingCodes?: number; |
| 72 | /** Pluggable storage backend for keys & client registrations. */ | 101 | /** Pluggable storage backend for keys, clients, and authorization codes. */ |
| 73 | storage?: OAuthStorage; | 102 | storage: OAuthStorage; |
| 74 | } | 103 | } |
| 75 | 104 | ||
| 76 | // --------------------------------------------------------------------------- | 105 | // --------------------------------------------------------------------------- |
| @@ -105,6 +134,26 @@ export interface OAuthStorage { · 28 unmodified lines | |||
| 105 | * Look up an OAuth client by client_id. Returns null if not found. | 134 | * Look up an OAuth client by client_id. Returns null if not found. |
| 106 | */ | 135 | */ |
| 107 | getClient(clientId: string): Promise<AnonOAuthClientRegistration | null>; | 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 | export class InMemoryOAuthStorage implements OAuthStorage { | 163 | export class InMemoryOAuthStorage implements OAuthStorage { |
| 115 | private signingKey: StoredSigningKey | null = null; | 164 | private signingKey: StoredSigningKey | null = null; |
| 116 | private readonly clients = new Map<string, AnonOAuthClientRegistration>(); | 165 | private readonly clients = new Map<string, AnonOAuthClientRegistration>(); |
| 166 | private readonly authorizationCodes = new Map<string, AuthorizationCode>(); | ||
| 117 | 167 | ||
| 118 | async getActiveSigningKey(): Promise<StoredSigningKey | null> { | 168 | async getActiveSigningKey(): Promise<StoredSigningKey | null> { |
| 119 | return this.signingKey; | 169 | return this.signingKey; |
| @@ -132,6 +182,34 @@ export class InMemoryOAuthStorage implements OAuthStorage { · 12 unmodified lines | |||
| 132 | return this.clients.get(clientId) ?? null; | 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 | /** Test helper: number of registered clients. */ | 213 | /** Test helper: number of registered clients. */ |
| 136 | get clientCount(): number { | 214 | get clientCount(): number { |
| 137 | return this.clients.size; | 215 | return this.clients.size; |
| @@ -153,11 +231,6 @@ export class AnonymousOAuthServer { · 15 unmodified lines | |||
| 153 | private privateKey!: JoseCryptoKey; | 231 | private privateKey!: JoseCryptoKey; |
| 154 | private publicJwk!: JWK; | 232 | private publicJwk!: JWK; |
| 155 | private keyId!: string; | 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 | constructor(config: AnonOAuthConfig) { | 235 | constructor(config: AnonOAuthConfig) { |
| 163 | this.issuer = config.issuer.replace(/\/+$/, ""); | 236 | this.issuer = config.issuer.replace(/\/+$/, ""); |
| @@ -165,7 +238,7 @@ export class AnonymousOAuthServer { · 1 unmodified line | |||
| 165 | this.accessTokenTtlSeconds = config.accessTokenTtlSeconds ?? 24 * 60 * 60; | 238 | this.accessTokenTtlSeconds = config.accessTokenTtlSeconds ?? 24 * 60 * 60; |
| 166 | this.codeExpirySeconds = config.codeExpirySeconds ?? 120; | 239 | this.codeExpirySeconds = config.codeExpirySeconds ?? 120; |
| 167 | this.maxPendingCodes = config.maxPendingCodes ?? 10_000; | 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 | * Initialize the OAuth server. | 249 | * Initialize the OAuth server. |
| 177 | * | 250 | * |
| 178 | * Attempts to load an existing signing key from storage. If none exists, | 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 | 252 | * generates a new RSA key pair and persists it. |
| 180 | * key and public JWK are cached locally for fast in-process operations. | ||
| 181 | */ | 253 | */ |
| 182 | async init(): Promise<void> { | 254 | async init(): Promise<void> { |
| 183 | const existing = await this.storage.getActiveSigningKey(); | 255 | const existing = await this.storage.getActiveSigningKey(); |
| @@ -205,9 +277,6 @@ export class AnonymousOAuthServer { · 21 unmodified lines | |||
| 205 | publicKeyJwk: publicJwk, | 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 | this.privateKey = privateKey; | 287 | this.privateKey = privateKey; |
| 219 | this.keyId = publicJwk.kid ?? `anon_key_${crypto.randomUUID().slice(0, 8)}`; | 288 | this.keyId = publicJwk.kid ?? `anon_key_${crypto.randomUUID().slice(0, 8)}`; |
| 220 | this.publicJwk = { ...publicJwk, kid: this.keyId, use: "sig", alg: "RS256" }; | 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 | * user to authenticate — we auto-approve and redirect back immediately | 360 | * user to authenticate — we auto-approve and redirect back immediately |
| 293 | * with an authorization code. | 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 | const responseType = params.get("response_type"); | 364 | const responseType = params.get("response_type"); |
| 297 | if (responseType !== "code") { | 365 | if (responseType !== "code") { |
| 298 | throw new OAuthBadRequest("response_type must be 'code'"); | 366 | throw new OAuthBadRequest("response_type must be 'code'"); |
| @@ -322,28 +390,32 @@ export class AnonymousOAuthServer { · 23 unmodified lines | |||
| 322 | throw new OAuthBadRequest("PKCE S256 code_challenge is required"); | 390 | throw new OAuthBadRequest("PKCE S256 code_challenge is required"); |
| 323 | } | 391 | } |
| 324 | 392 | ||
| 325 | // Generate anonymous identity | 393 | // Generate or bind anonymous identity |
| 326 | const actorId = `anon_${crypto.randomUUID()}`; | 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 | 399 | // Enforce cap on pending codes to prevent unbounded growth |
| 329 | if (this.codes.size >= this.maxPendingCodes) { | 400 | if (await this.storage.countAuthorizationCodes() >= this.maxPendingCodes) { |
| 330 | // Purge expired codes first; if still over limit, reject | 401 | await this.storage.purgeExpiredAuthorizationCodes(Date.now()); |
| 331 | this.purgeExpiredCodes(); | 402 | if (await this.storage.countAuthorizationCodes() >= this.maxPendingCodes) { |
| 332 | if (this.codes.size >= this.maxPendingCodes) { | ||
| 333 | throw new OAuthBadRequest("Too many pending authorization requests — try again later"); | 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 | const code = crypto.randomUUID(); | 408 | const code = crypto.randomUUID(); |
| 339 | this.codes.set(code, { | 409 | await this.storage.storeAuthorizationCode({ |
| 340 | code, | 410 | code, |
| 341 | clientId, | 411 | clientId, |
| 342 | redirectUri, | 412 | redirectUri, |
| 343 | codeChallenge, | 413 | codeChallenge, |
| 344 | codeChallengeMethod, | 414 | codeChallengeMethod, |
| 345 | actorId, | 415 | actorId, |
| 416 | tokenClaims: sanitizeTokenClaims(options?.tokenClaims), | ||
| 346 | expiresAt: Date.now() + this.codeExpirySeconds * 1000, | 417 | expiresAt: Date.now() + this.codeExpirySeconds * 1000, |
| 418 | createdAt: Date.now(), | ||
| 347 | }); | 419 | }); |
| 348 | 420 | ||
| 349 | // Build redirect URL | 421 | // Build redirect URL |
| @@ -380,14 +452,11 @@ export class AnonymousOAuthServer { · 30 unmodified lines | |||
| 380 | throw new OAuthBadRequest("code is required"); | 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 | if (!storedCode) { | 456 | if (!storedCode) { |
| 385 | throw new OAuthBadRequest("invalid or expired code"); | 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 | if (Date.now() > storedCode.expiresAt) { | 460 | if (Date.now() > storedCode.expiresAt) { |
| 392 | throw new OAuthBadRequest("authorization code has expired"); | 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 | // Mint JWT | 487 | // Mint JWT |
| 419 | const accessToken = await new SignJWT({ | 488 | const accessTokenClaims = { |
| 420 | sub: storedCode.actorId, | ||
| 421 | provider: "anonymous", | 489 | provider: "anonymous", |
| 422 | }) | 490 | ...(storedCode.tokenClaims ?? {}), |
| 491 | }; | ||
| 492 | |||
| 493 | const accessToken = await new SignJWT(accessTokenClaims) | ||
| 423 | .setProtectedHeader({ alg: "RS256", kid: this.keyId }) | 494 | .setProtectedHeader({ alg: "RS256", kid: this.keyId }) |
| 495 | .setSubject(storedCode.actorId) | ||
| 424 | .setIssuer(this.issuer) | 496 | .setIssuer(this.issuer) |
| 425 | .setAudience(this.audience) | 497 | .setAudience(this.audience) |
| 426 | .setIssuedAt() | 498 | .setIssuedAt() |
| @@ -441,9 +513,9 @@ export class AnonymousOAuthServer { · 14 unmodified lines | |||
| 441 | 513 | ||
| 442 | async verifyToken( | 514 | async verifyToken( |
| 443 | token: string, | 515 | token: string, |
| 444 | ): Promise<{ sub: string; provider: string } | null> { | 516 | ): Promise<{ sub: string; provider: string; claims: Record<string, unknown> } | null> { |
| 445 | try { | 517 | try { |
| 446 | const { payload } = await jwtVerify(token, this.localJwks, { | 518 | const { payload } = await jwtVerify(token, createLocalJWKSet({ keys: [this.publicJwk] }), { |
| 447 | issuer: this.issuer, | 519 | issuer: this.issuer, |
| 448 | audience: this.audience, | 520 | audience: this.audience, |
| 449 | }); | 521 | }); |
| @@ -455,6 +527,9 @@ export class AnonymousOAuthServer { · 5 unmodified lines | |||
| 455 | return { | 527 | return { |
| 456 | sub: payload.sub, | 528 | sub: payload.sub, |
| 457 | provider: typeof payload.provider === "string" ? payload.provider : "anonymous", | 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 | } catch { | 534 | } catch { |
| 460 | return null; | 535 | return null; |
| @@ -469,21 +544,13 @@ export class AnonymousOAuthServer { · 8 unmodified lines | |||
| 469 | return this.issuer; | 544 | return this.issuer; |
| 470 | } | 545 | } |
| 471 | 546 | ||
| 472 | getCodeCount(): number { | 547 | async getCodeCount(): Promise<number> { |
| 473 | return this.codes.size; | 548 | return await this.storage.countAuthorizationCodes(); |
| 474 | } | 549 | } |
| 475 | 550 | ||
| 476 | /** Purge expired authorization codes (call periodically). */ | 551 | /** Purge expired authorization codes (call periodically). */ |
| 477 | purgeExpiredCodes(): number { | 552 | async purgeExpiredCodes(): Promise<number> { |
| 478 | const now = Date.now(); | 553 | return await this.storage.purgeExpiredAuthorizationCodes(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; | ||
| 487 | } | 554 | } |
| 488 | } | 555 | } |
| 489 | 556 | ||
| @@ -1,9 +1,8 @@ | |||
| 1 | /** | 1 | /** |
| 2 | * Convex-backed storage for the anonymous OAuth server. | 2 | * Convex-backed storage for the anonymous OAuth server. |
| 3 | * | 3 | * |
| 4 | * Persists signing keys and client registrations to Convex so they | 4 | * Persists signing keys, client registrations, and authorization codes |
| 5 | * survive gateway restarts. Keys are cached locally after first load | 5 | * to Convex so OAuth flows survive gateway restarts. |
| 6 | * for fast in-process token verification. | ||
| 7 | * | 6 | * |
| 8 | * All Convex calls include an `internalSecret` argument that is validated | 7 | * All Convex calls include an `internalSecret` argument that is validated |
| 9 | * server-side against `EXECUTOR_INTERNAL_TOKEN`, preventing unauthorized | 8 | * server-side against `EXECUTOR_INTERNAL_TOKEN`, preventing unauthorized |
| @@ -97,4 +96,69 @@ export class ConvexOAuthStorage implements OAuthStorage { · 87 unmodified lines | |||
| 97 | created_at: result.created_at, | 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 | import { expect, test } from "bun:test"; | 1 | import { expect, test } from "bun:test"; |
| 2 | import { resolveCredentialPayload } from "./credential-providers"; | 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 | const payload = await resolveCredentialPayload({ | 5 | const payload = await resolveCredentialPayload({ |
| 6 | provider: "managed", | 6 | provider: "local-convex", |
| 7 | secretJson: { token: "managed-token" }, | 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 | test("WorkOS Vault provider reads object and parses JSON", async () => { | 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 | return value.trim(); | 105 | return value.trim(); |
| 106 | } | 106 | } |
| 107 | 107 | ||
| 108 | async function resolveManaged( | 108 | async function resolveLocalConvex( |
| 109 | record: Pick<CredentialRecord, "secretJson">, | 109 | record: Pick<CredentialRecord, "secretJson">, |
| 110 | ): Promise<CredentialPayload | null> { | 110 | ): Promise<CredentialPayload | null> { |
| 111 | return asRecord(record.secretJson); | 111 | return asRecord(record.secretJson); |
| @@ -135,7 +135,7 @@ async function resolveWorkosVault( · 23 unmodified lines | |||
| 135 | } | 135 | } |
| 136 | 136 | ||
| 137 | const providers: Record<CredentialProvider, ProviderResolver> = { | 137 | const providers: Record<CredentialProvider, ProviderResolver> = { |
| 138 | managed: resolveManaged, | 138 | "local-convex": resolveLocalConvex, |
| 139 | "workos-vault": resolveWorkosVault, | 139 | "workos-vault": resolveWorkosVault, |
| 140 | }; | 140 | }; |
| 141 | 141 | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | import { expect, test } from "bun:test"; | 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 | let requestCount = 0; | 5 | let requestCount = 0; |
| 6 | const server = Bun.serve({ | 6 | const server = Bun.serve({ |
| 7 | port: 0, | 7 | port: 0, |
| @@ -14,50 +14,51 @@ test("loadSourceDtsByUrlCached reuses cached URL fetches", async () => { · 6 unmodified lines | |||
| 14 | }); | 14 | }); |
| 15 | 15 | ||
| 16 | try { | 16 | try { |
| 17 | clearDtsUrlCache(); | ||
| 18 | const url = `http://127.0.0.1:${server.port}/types.d.ts`; | 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 | "openapi:one": url, | 20 | "openapi:one": url, |
| 22 | "openapi:two": url, | 21 | "openapi:two": url, |
| 23 | }); | 22 | }); |
| 24 | const second = await loadSourceDtsByUrlCached({ | 23 | const second = await loadSourceDtsByUrl({ |
| 25 | "openapi:one": url, | 24 | "openapi:one": url, |
| 26 | }); | 25 | }); |
| 27 | 26 | ||
| 28 | expect(first["openapi:one"]).toContain("interface operations"); | 27 | expect(first["openapi:one"]).toContain("interface operations"); |
| 29 | expect(first["openapi:two"]).toContain("interface operations"); | 28 | expect(first["openapi:two"]).toContain("interface operations"); |
| 30 | expect(second["openapi:one"]).toContain("interface operations"); | 29 | expect(second["openapi:one"]).toContain("interface operations"); |
| 31 | expect(requestCount).toBe(1); | 30 | expect(requestCount).toBe(3); |
| 32 | } finally { | 31 | } finally { |
| 33 | server.stop(true); | 32 | server.stop(true); |
| 34 | clearDtsUrlCache(); | ||
| 35 | } | 33 | } |
| 36 | }); | 34 | }); |
| 37 | 35 | ||
| 38 | test("loadSourceDtsByUrlCached respects ttl expiration", async () => { | 36 | test("loadSourceDtsByUrl skips sources that fail to download", async () => { |
| 39 | let requestCount = 0; | ||
| 40 | const server = Bun.serve({ | 37 | const server = Bun.serve({ |
| 41 | port: 0, | 38 | port: 0, |
| 42 | fetch: () => { | 39 | fetch: (request) => { |
| 43 | requestCount += 1; | 40 | const url = new URL(request.url); |
| 44 | return new Response("export interface operations { ping: {} }", { | 41 | if (url.pathname === "/fail.d.ts") { |
| 42 | return new Response("nope", { status: 500 }); | ||
| 43 | } | ||
| 44 | return new Response("export interface operations { pong: {} }", { | ||
| 45 | headers: { "content-type": "text/plain" }, | 45 | headers: { "content-type": "text/plain" }, |
| 46 | }); | 46 | }); |
| 47 | }, | 47 | }, |
| 48 | }); | 48 | }); |
| 49 | 49 | ||
| 50 | try { | 50 | try { |
| 51 | clearDtsUrlCache(); | 51 | const okUrl = `http://127.0.0.1:${server.port}/ok.d.ts`; |
| 52 | const url = `http://127.0.0.1:${server.port}/types.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 }); | 54 | const loaded = await loadSourceDtsByUrl({ |
| 55 | await Bun.sleep(20); | 55 | "openapi:ok": okUrl, |
| 56 | await loadSourceDtsByUrlCached({ "openapi:one": url }, { ttlMs: 5 }); | 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 | } finally { | 61 | } finally { |
| 60 | server.stop(true); | 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 | async function fetchDtsText(url: string): Promise<string | null> { | 1 | async function fetchDtsText(url: string): Promise<string | null> { |
| 11 | try { | 2 | try { |
| 12 | const response = await fetch(url); | 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> { | 17 | export async function loadSourceDtsByUrl( |
| 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( | ||
| 42 | dtsUrls: Record<string, string>, | 18 | dtsUrls: Record<string, string>, |
| 43 | options?: { ttlMs?: number }, | ||
| 44 | ): Promise<Record<string, string>> { | 19 | ): Promise<Record<string, string>> { |
| 45 | const entries = Object.entries(dtsUrls); | 20 | const entries = Object.entries(dtsUrls); |
| 46 | if (entries.length === 0) { | 21 | if (entries.length === 0) { |
| 47 | return {}; | 22 | return {}; |
| 48 | } | 23 | } |
| 49 | 24 | ||
| 50 | const ttlMs = options?.ttlMs ?? DEFAULT_DTS_CACHE_TTL_MS; | ||
| 51 | const results = await Promise.all(entries.map(async ([sourceKey, url]) => { | 25 | const results = await Promise.all(entries.map(async ([sourceKey, url]) => { |
| 52 | if (!url) return [sourceKey, null] as const; | 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 | const sourceDtsBySource: Record<string, string> = {}; | 30 | const sourceDtsBySource: Record<string, string> = {}; |
| @@ -61,7 +35,3 @@ export async function loadSourceDtsByUrlCached( · 4 unmodified lines | |||
| 61 | } | 35 | } |
| 62 | return sourceDtsBySource; | 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 | workspaceMembership: WorkspaceAccessMembership; | 24 | workspaceMembership: WorkspaceAccessMembership; |
| 25 | }; | 25 | }; |
| 26 | 26 | ||
| 27 | export function slugify(input: string): string { | 27 | export function slugify(input: string, fallback = "team"): string { |
| 28 | const slug = input | 28 | const slug = input |
| 29 | .toLowerCase() | 29 | .toLowerCase() |
| 30 | .replace(/[^a-z0-9]+/g, "-") | 30 | .replace(/[^a-z0-9]+/g, "-") |
| 31 | .replace(/^-+|-+$/g, ""); | 31 | .replace(/^-+|-+$/g, ""); |
| 32 | return slug.length > 0 ? slug : "team"; | 32 | return slug.length > 0 ? slug : fallback; |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | export async function resolveAccountForRequest( | 35 | export async function resolveAccountForRequest( |
| @@ -4,7 +4,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ · 3 unmodified lines | |||
| 4 | import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; | 4 | import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 5 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; | 5 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 6 | import type { OAuthClientMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; | 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 | import { handleMcpRequest, type McpWorkspaceContext } from "./mcp-server"; | 8 | import { handleMcpRequest, type McpWorkspaceContext } from "./mcp-server"; |
| 9 | import type { AnonymousContext, CreateTaskInput, TaskRecord, ToolDescriptor } from "./types"; | 9 | import type { AnonymousContext, CreateTaskInput, TaskRecord, ToolDescriptor } from "./types"; |
| 10 | import type { LiveTaskEvent } from "./events"; | 10 | import type { LiveTaskEvent } from "./events"; |
| @@ -55,8 +55,7 @@ class FakeMcpService { · 44 unmodified lines | |||
| 55 | completedAt: current.createdAt + 2, | 55 | completedAt: current.createdAt + 2, |
| 56 | updatedAt: current.createdAt + 2, | 56 | updatedAt: current.createdAt + 2, |
| 57 | exitCode: 0, | 57 | exitCode: 0, |
| 58 | stdout: `ran:${input.code.slice(0, 20)}`, | 58 | result: { ran: input.code.slice(0, 20) }, |
| 59 | stderr: "", | ||
| 60 | }); | 59 | }); |
| 61 | for (const listener of this.listeners.get(id) ?? []) { | 60 | for (const listener of this.listeners.get(id) ?? []) { |
| 62 | listener({ id: 1, eventName: "task", payload: { status: "completed" }, createdAt: Date.now() }); | 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 | baseUrl = `http://127.0.0.1:${server.port}`; | 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 | await oauthServer.init(); | 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 | // We import the gateway init helpers directly to avoid needing CONVEX_URL | 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 | let server: ReturnType<typeof Bun.serve>; | 24 | let server: ReturnType<typeof Bun.serve>; |
| 25 | let baseUrl: string; | 25 | let baseUrl: string; |
| @@ -44,7 +44,10 @@ function generateCodeVerifier(): string { · 18 unmodified lines | |||
| 44 | beforeAll(async () => { | 44 | beforeAll(async () => { |
| 45 | // We start our own server so we don't need CONVEX_URL. | 45 | // We start our own server so we don't need CONVEX_URL. |
| 46 | const port = 0; // random available port | 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 | await oauthServer.init(); | 51 | await oauthServer.init(); |
| 49 | 52 | ||
| 50 | server = Bun.serve({ | 53 | server = Bun.serve({ |
| @@ -172,7 +175,10 @@ beforeAll(async () => { · 121 unmodified lines | |||
| 172 | 175 | ||
| 173 | // Update the oauth server's issuer to match the real URL | 176 | // Update the oauth server's issuer to match the real URL |
| 174 | // We do this by re-initializing with the correct issuer | 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 | await oauthServer.init(); | 182 | await oauthServer.init(); |
| 177 | }); | 183 | }); |
| 178 | 184 | ||
| @@ -44,10 +44,9 @@ class FakeMcpService { · 43 unmodified lines | |||
| 44 | completedAt: current.createdAt + 2, | 44 | completedAt: current.createdAt + 2, |
| 45 | updatedAt: current.createdAt + 2, | 45 | updatedAt: current.createdAt + 2, |
| 46 | exitCode: 0, | 46 | exitCode: 0, |
| 47 | stdout: input.metadata?.largeStdout === true | 47 | result: input.metadata?.largeResult === true |
| 48 | ? `header\n${"x".repeat(35_000)}` | 48 | ? { data: `header\n${"x".repeat(35_000)}` } |
| 49 | : `ran:${input.code.slice(0, 20)}`, | 49 | : { ran: input.code.slice(0, 20) }, |
| 50 | stderr: "", | ||
| 51 | }); | 50 | }); |
| 52 | // Notify subscribers | 51 | // Notify subscribers |
| 53 | for (const listener of this.listeners.get(id) ?? []) { | 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 | expect(text?.type).toBe("text"); | 163 | expect(text?.type).toBe("text"); |
| 165 | if (text?.type === "text") { | 164 | if (text?.type === "text") { |
| 166 | expect(text.text).toContain("status: completed"); | 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 | const service = new FakeMcpService(); | 172 | const service = new FakeMcpService(); |
| 174 | 173 | ||
| 175 | await withMcpClient(service, async (client) => { | 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 | name: "run_code", | 176 | name: "run_code", |
| 178 | arguments: { | 177 | arguments: { |
| 179 | code: "console.log('large')", | 178 | code: "console.log('large')", |
| 180 | metadata: { largeStdout: true }, | 179 | metadata: { largeResult: true }, |
| 181 | }, | 180 | }, |
| 182 | })) as { | 181 | })) as { |
| 183 | isError?: boolean; | 182 | isError?: boolean; |
| @@ -189,15 +188,11 @@ test("run_code MCP tool warns and previews large output", async () => { · 5 unmodified lines | |||
| 189 | const text = result.content.find((part) => part.type === "text"); | 188 | const text = result.content.find((part) => part.type === "text"); |
| 190 | expect(text?.type).toBe("text"); | 189 | expect(text?.type).toBe("text"); |
| 191 | if (text?.type === "text") { | 190 | if (text?.type === "text") { |
| 192 | expect(text.text).toContain("outputWarning: Large runtime output detected"); | 191 | expect(text.text).toContain("[result preview truncated"); |
| 193 | expect(text.text).toContain("outputHint: Return compact summaries"); | ||
| 194 | expect(text.text).toContain("[stdout preview truncated"); | ||
| 195 | } | 192 | } |
| 196 | 193 | ||
| 197 | const fullStdout = typeof result.structuredContent?.stdout === "string" | 194 | const fullResult = result.structuredContent?.result as { data?: string } | undefined; |
| 198 | ? result.structuredContent.stdout | 195 | expect((fullResult?.data ?? "").length).toBeGreaterThan(30_000); |
| 199 | : ""; | ||
| 200 | expect(fullStdout.length).toBeGreaterThan(30_000); | ||
| 201 | }); | 196 | }); |
| 202 | }); | 197 | }); |
| 203 | 198 | ||
| @@ -1,7 +1,8 @@ | |||
| 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 2 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | 2 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| 3 | import { Result } from "better-result"; | ||
| 3 | import { z } from "zod"; | 4 | import { z } from "zod"; |
| 4 | import { loadSourceDtsByUrlCached } from "./dts-loader"; | 5 | import { loadSourceDtsByUrl } from "./dts-loader"; |
| 5 | import { generateToolDeclarations, generateToolInventory, typecheckCode } from "./typechecker"; | 6 | import { generateToolDeclarations, generateToolInventory, typecheckCode } from "./typechecker"; |
| 6 | import type { LiveTaskEvent } from "./events"; | 7 | import type { LiveTaskEvent } from "./events"; |
| 7 | import type { | 8 | import type { |
| @@ -94,7 +95,7 @@ function listTopLevelToolKeys(tools: ToolDescriptor[]): string[] { · 86 unmodified lines | |||
| 94 | } | 95 | } |
| 95 | 96 | ||
| 96 | function summarizeTask(task: TaskRecord): string { | 97 | function summarizeTask(task: TaskRecord): string { |
| 97 | const maxStreamPreviewChars = 30_000; | 98 | const maxResultPreviewChars = 30_000; |
| 98 | const lines = [ | 99 | const lines = [ |
| 99 | `taskId: ${task.id}`, | 100 | `taskId: ${task.id}`, |
| 100 | `status: ${task.status}`, | 101 | `status: ${task.status}`, |
| @@ -109,29 +110,17 @@ function summarizeTask(task: TaskRecord): string { · 8 unmodified lines | |||
| 109 | lines.push(`error: ${task.error}`); | 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 | let text = lines.join("\n"); | 113 | let text = lines.join("\n"); |
| 124 | if (task.stdout && task.stdout.trim()) { | 114 | if (task.result !== undefined) { |
| 125 | const stdoutPreview = task.stdout.length > maxStreamPreviewChars | 115 | const serialized = Result.try(() => JSON.stringify(task.result, null, 2)).unwrapOr(String(task.result)); |
| 126 | ? `${task.stdout.slice(0, maxStreamPreviewChars)}\n... [stdout preview truncated ${task.stdout.length - maxStreamPreviewChars} chars]` | 116 | if (serialized.length > maxResultPreviewChars) { |
| 127 | : task.stdout; | 117 | text += asCodeBlock( |
| 128 | text += asCodeBlock("text", stdoutPreview); | 118 | "json", |
| 129 | } | 119 | `${serialized.slice(0, maxResultPreviewChars)}\n... [result preview truncated ${serialized.length - maxResultPreviewChars} chars]`, |
| 130 | if (task.stderr && task.stderr.trim()) { | 120 | ); |
| 131 | const stderrPreview = task.stderr.length > maxStreamPreviewChars | 121 | } else { |
| 132 | ? `${task.stderr.slice(0, maxStreamPreviewChars)}\n... [stderr preview truncated ${task.stderr.length - maxStreamPreviewChars} chars]` | 122 | text += asCodeBlock("json", serialized); |
| 133 | : task.stderr; | 123 | } |
| 134 | text += asCodeBlock("text", stderrPreview); | ||
| 135 | } | 124 | } |
| 136 | return text; | 125 | return text; |
| 137 | } | 126 | } |
| @@ -260,7 +249,7 @@ function waitForTerminalTask( · 122 unmodified lines | |||
| 260 | 249 | ||
| 261 | function buildRunCodeDescription(tools?: ToolDescriptor[]): string { | 250 | function buildRunCodeDescription(tools?: ToolDescriptor[]): string { |
| 262 | const base = | 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 | const toolList = tools ?? []; | 253 | const toolList = tools ?? []; |
| 265 | const topLevelKeys = listTopLevelToolKeys(toolList); | 254 | const topLevelKeys = listTopLevelToolKeys(toolList); |
| 266 | const rootKeysNote = topLevelKeys.length > 0 | 255 | const rootKeysNote = topLevelKeys.length > 0 |
| @@ -428,7 +417,7 @@ function createRunCodeTool( · 161 unmodified lines | |||
| 428 | clientId: context.clientId, | 417 | clientId: context.clientId, |
| 429 | }); | 418 | }); |
| 430 | toolsForContext = tools; | 419 | toolsForContext = tools; |
| 431 | sourceDtsBySource = await loadSourceDtsByUrlCached(dtsUrls ?? {}); | 420 | sourceDtsBySource = await loadSourceDtsByUrl(dtsUrls ?? {}); |
| 432 | } else { | 421 | } else { |
| 433 | toolsForContext = await service.listTools({ | 422 | toolsForContext = await service.listTools({ |
| 434 | workspaceId: context.workspaceId, | 423 | workspaceId: context.workspaceId, |
| @@ -537,8 +526,7 @@ function createRunCodeTool( · 102 unmodified lines | |||
| 537 | runtimeId: task.runtimeId, | 526 | runtimeId: task.runtimeId, |
| 538 | exitCode: task.exitCode, | 527 | exitCode: task.exitCode, |
| 539 | error: task.error, | 528 | error: task.error, |
| 540 | stdout: task.stdout, | 529 | result: task.result, |
| 541 | stderr: task.stderr, | ||
| 542 | workspaceId: context.workspaceId, | 530 | workspaceId: context.workspaceId, |
| 543 | actorId: context.actorId, | 531 | actorId: context.actorId, |
| 544 | sessionId: context.sessionId, | 532 | sessionId: context.sessionId, |
| @@ -606,36 +594,6 @@ async function createMcpServer( · 61 unmodified lines | |||
| 606 | return mcp; | 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 | async function handleStatelessMcpRequest( | 597 | async function handleStatelessMcpRequest( |
| 640 | service: McpExecutorService, | 598 | service: McpExecutorService, |
| 641 | request: Request, | 599 | request: Request, |
| @@ -665,64 +623,8 @@ export async function handleMcpRequest( · 23 unmodified lines | |||
| 665 | request: Request, | 623 | request: Request, |
| 666 | context?: McpWorkspaceContext, | 624 | context?: McpWorkspaceContext, |
| 667 | ): Promise<Response> { | 625 | ): Promise<Response> { |
| 668 | const runtimeSessionId = request.headers.get("mcp-session-id") ?? undefined; | 626 | const headers = new Headers(request.headers); |
| 669 | 627 | headers.delete("mcp-session-id"); | |
| 670 | if (runtimeSessionId) { | 628 | const requestWithoutSession = new Request(request, { headers }); |
| 671 | const runtime = mcpSessionRuntimes[runtimeSessionId]; | 629 | return await handleStatelessMcpRequest(service, requestWithoutSession, context); |
| 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 | } | ||
| 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 | test("infers workspace auth from OpenAPI securitySchemes when source auth is unset", async () => { | 441 | test("infers workspace auth from OpenAPI securitySchemes when source auth is unset", async () => { |
| 349 | const spec: Record<string, unknown> = { | 442 | const spec: Record<string, unknown> = { |
| 350 | openapi: "3.0.3", | 443 | openapi: "3.0.3", |
| @@ -21,8 +21,8 @@ import { transpileForRuntime } from "./transpile"; · 20 unmodified lines | |||
| 21 | * `ToolBridge` entrypoint in the host Worker (passed via `env` bindings), | 21 | * `ToolBridge` entrypoint in the host Worker (passed via `env` bindings), |
| 22 | * which in turn calls Convex callback RPCs to resolve tools. | 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 | 24 | * 4. Only explicit `return` values from the isolate are included in callback |
| 25 | * RPC mutation. | 25 | * completion payloads. |
| 26 | * | 26 | * |
| 27 | * 5. The host Worker accepts the run immediately and finishes execution | 27 | * 5. The host Worker accepts the run immediately and finishes execution |
| 28 | * asynchronously, reporting terminal results back through callback RPC. | 28 | * asynchronously, reporting terminal results back through callback RPC. |
| @@ -4,7 +4,6 @@ import { loadExternalTools } from "../tool-sources"; · 3 unmodified lines | |||
| 4 | import type { Id } from "../../../convex/_generated/dataModel"; | 4 | import type { Id } from "../../../convex/_generated/dataModel"; |
| 5 | import type { | 5 | import type { |
| 6 | ExecutionAdapter, | 6 | ExecutionAdapter, |
| 7 | RuntimeOutputEvent, | ||
| 8 | SandboxExecutionRequest, | 7 | SandboxExecutionRequest, |
| 9 | ToolDefinition, | 8 | ToolDefinition, |
| 10 | ToolRunContext, | 9 | ToolRunContext, |
| @@ -20,7 +19,6 @@ function request(code: string, timeoutMs = 1_000): SandboxExecutionRequest { · 9 unmodified lines | |||
| 20 | 19 | ||
| 21 | function createRuntimeAdapter( | 20 | function createRuntimeAdapter( |
| 22 | tools: Map<string, ToolDefinition>, | 21 | tools: Map<string, ToolDefinition>, |
| 23 | outputEvents: RuntimeOutputEvent[], | ||
| 24 | ): ExecutionAdapter { | 22 | ): ExecutionAdapter { |
| 25 | return { | 23 | return { |
| 26 | async invokeTool(call) { | 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 () => { | 55 | test("executes tool calls and captures returned result", async () => { |
| 61 | const outputEvents: RuntimeOutputEvent[] = []; | ||
| 62 | const tools = new Map<string, ToolDefinition>([ | 56 | const tools = new Map<string, ToolDefinition>([ |
| 63 | [ | 57 | [ |
| 64 | "utils.echo", | 58 | "utils.echo", |
| @@ -74,16 +68,13 @@ test("executes tool calls and captures output", async () => { · 9 unmodified lines | |||
| 74 | const result = await runCodeWithAdapter( | 68 | const result = await runCodeWithAdapter( |
| 75 | request(` | 69 | request(` |
| 76 | const out = await tools.utils.echo({ message: "hi" }); | 70 | const out = await tools.utils.echo({ message: "hi" }); |
| 77 | console.log("out", out.echoed.message); | 71 | return { message: out.echoed.message, total: 42 }; |
| 78 | return 42; | ||
| 79 | `), | 72 | `), |
| 80 | createRuntimeAdapter(tools, outputEvents), | 73 | createRuntimeAdapter(tools), |
| 81 | ); | 74 | ); |
| 82 | 75 | ||
| 83 | expect(result.status).toBe("completed"); | 76 | expect(result.status).toBe("completed"); |
| 84 | expect(result.stdout).toContain("out hi"); | 77 | expect(result.result).toEqual({ message: "hi", total: 42 }); |
| 85 | expect(result.stdout).toContain("result: 42"); | ||
| 86 | expect(outputEvents.some((event) => event.stream === "stdout")).toBe(true); | ||
| 87 | }); | 78 | }); |
| 88 | 79 | ||
| 89 | test("returns denied when adapter marks tool call denied", async () => { | 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 | error: "policy denied", | 86 | error: "policy denied", |
| 96 | }; | 87 | }; |
| 97 | }, | 88 | }, |
| 98 | emitOutput() {}, | ||
| 99 | }; | 89 | }; |
| 100 | 90 | ||
| 101 | const result = await runCodeWithAdapter( | 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 | expect(result.status).toBe("denied"); | 98 | expect(result.status).toBe("denied"); |
| 109 | expect(result.error).toBe("policy denied"); | 99 | expect(result.error).toBe("policy denied"); |
| 110 | expect(result.stderr).toContain("policy denied"); | ||
| 111 | }); | 100 | }); |
| 112 | 101 | ||
| 113 | test("times out long-running code", async () => { | 102 | test("times out long-running code", async () => { |
| @@ -115,7 +104,6 @@ test("times out long-running code", async () => { · 1 unmodified line | |||
| 115 | async invokeTool() { | 104 | async invokeTool() { |
| 116 | return { ok: true, value: null }; | 105 | return { ok: true, value: null }; |
| 117 | }, | 106 | }, |
| 118 | emitOutput() {}, | ||
| 119 | }; | 107 | }; |
| 120 | 108 | ||
| 121 | const result = await runCodeWithAdapter( | 109 | const result = await runCodeWithAdapter( |
| @@ -133,8 +121,7 @@ test("times out long-running code", async () => { · 11 unmodified lines | |||
| 133 | }); | 121 | }); |
| 134 | 122 | ||
| 135 | test("sandboxed runner does not expose host globals", async () => { | 123 | test("sandboxed runner does not expose host globals", async () => { |
| 136 | const outputEvents: RuntimeOutputEvent[] = []; | 124 | const adapter = createRuntimeAdapter(new Map()); |
| 137 | const adapter = createRuntimeAdapter(new Map(), outputEvents); | ||
| 138 | 125 | ||
| 139 | const result = await runCodeWithAdapter( | 126 | const result = await runCodeWithAdapter( |
| 140 | request(` | 127 | request(` |
| @@ -151,27 +138,28 @@ test("sandboxed runner does not expose host globals", async () => { · 10 unmodified lines | |||
| 151 | fetch: typeof fetch, | 138 | fetch: typeof fetch, |
| 152 | fsEscape, | 139 | fsEscape, |
| 153 | }; | 140 | }; |
| 154 | console.log(JSON.stringify(checks)); | ||
| 155 | if (checks.process !== "undefined") throw new Error("process leaked"); | 141 | if (checks.process !== "undefined") throw new Error("process leaked"); |
| 156 | if (checks.bun !== "undefined") throw new Error("Bun leaked"); | 142 | if (checks.bun !== "undefined") throw new Error("Bun leaked"); |
| 157 | if (checks.fetch !== "undefined") throw new Error("fetch leaked"); | 143 | if (checks.fetch !== "undefined") throw new Error("fetch leaked"); |
| 158 | if (checks.fsEscape !== "undefined" && checks.fsEscape !== "blocked") { | 144 | if (checks.fsEscape !== "undefined" && checks.fsEscape !== "blocked") { |
| 159 | throw new Error("constructor escape leaked"); | 145 | throw new Error("constructor escape leaked"); |
| 160 | } | 146 | } |
| 161 | return "ok"; | 147 | return checks; |
| 162 | `), | 148 | `), |
| 163 | adapter, | 149 | adapter, |
| 164 | ); | 150 | ); |
| 165 | 151 | ||
| 166 | expect(result.status).toBe("completed"); | 152 | expect(result.status).toBe("completed"); |
| 167 | expect(result.stdout).toContain("\"process\":\"undefined\""); | 153 | expect(result.result).toEqual({ |
| 168 | expect(result.stdout).toContain("\"bun\":\"undefined\""); | 154 | process: "undefined", |
| 169 | expect(result.stdout).toContain("\"fetch\":\"undefined\""); | 155 | bun: "undefined", |
| 170 | expect(result.stdout).toMatch(/"fsEscape":"(undefined|blocked)"/); | 156 | fetch: "undefined", |
| 157 | fsEscape: expect.stringMatching(/^(undefined|blocked)$/), | ||
| 158 | }); | ||
| 171 | }); | 159 | }); |
| 172 | 160 | ||
| 173 | test("sandbox blocks global constructor and eval escapes", async () => { | 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 | const result = await runCodeWithAdapter( | 164 | const result = await runCodeWithAdapter( |
| 177 | request(` | 165 | request(` |
| @@ -197,25 +185,27 @@ test("sandbox blocks global constructor and eval escapes", async () => { · 19 unmodified lines | |||
| 197 | functionEscape = "blocked"; | 185 | functionEscape = "blocked"; |
| 198 | } | 186 | } |
| 199 | 187 | ||
| 200 | console.log(JSON.stringify({ ctorEscape, evalEscape, functionEscape })); | ||
| 201 | if (ctorEscape !== "undefined" && ctorEscape !== "blocked") throw new Error("ctor escape"); | 188 | if (ctorEscape !== "undefined" && ctorEscape !== "blocked") throw new Error("ctor escape"); |
| 202 | if (evalEscape !== "undefined" && evalEscape !== "blocked") throw new Error("eval escape"); | 189 | if (evalEscape !== "undefined" && evalEscape !== "blocked") throw new Error("eval escape"); |
| 203 | if (functionEscape !== "undefined" && functionEscape !== "blocked") throw new Error("Function escape"); | 190 | if (functionEscape !== "undefined" && functionEscape !== "blocked") throw new Error("Function escape"); |
| 191 | return { ctorEscape, evalEscape, functionEscape }; | ||
| 204 | `), | 192 | `), |
| 205 | adapter, | 193 | adapter, |
| 206 | ); | 194 | ); |
| 207 | 195 | ||
| 208 | expect(result.status).toBe("completed"); | 196 | expect(result.status).toBe("completed"); |
| 209 | expect(result.stdout).toMatch(/"ctorEscape":"(undefined|blocked)"/); | 197 | expect(result.result).toEqual({ |
| 210 | expect(result.stdout).toMatch(/"evalEscape":"(undefined|blocked)"/); | 198 | ctorEscape: expect.stringMatching(/^(undefined|blocked)$/), |
| 211 | expect(result.stdout).toMatch(/"functionEscape":"(undefined|blocked)"/); | 199 | evalEscape: expect.stringMatching(/^(undefined|blocked)$/), |
| 200 | functionEscape: expect.stringMatching(/^(undefined|blocked)$/), | ||
| 201 | }); | ||
| 212 | }); | 202 | }); |
| 213 | 203 | ||
| 214 | test("sandbox prototype pollution does not leak to host", async () => { | 204 | test("sandbox prototype pollution does not leak to host", async () => { |
| 215 | const marker = `__pwned_${crypto.randomUUID().replace(/-/g, "")}`; | 205 | const marker = `__pwned_${crypto.randomUUID().replace(/-/g, "")}`; |
| 216 | expect(({} as Record<string, unknown>)[marker]).toBeUndefined(); | 206 | expect(({} as Record<string, unknown>)[marker]).toBeUndefined(); |
| 217 | 207 | ||
| 218 | const adapter = createRuntimeAdapter(new Map(), []); | 208 | const adapter = createRuntimeAdapter(new Map()); |
| 219 | const result = await runCodeWithAdapter( | 209 | const result = await runCodeWithAdapter( |
| 220 | request(` | 210 | request(` |
| 221 | Object.prototype[${JSON.stringify(marker)}] = "yes"; | 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 | const toolMap = new Map(tools.map((tool) => [tool.path, tool])); | 351 | const toolMap = new Map(tools.map((tool) => [tool.path, tool])); |
| 362 | const outputEvents: RuntimeOutputEvent[] = []; | ||
| 363 | |||
| 364 | const result = await runCodeWithAdapter( | 352 | const result = await runCodeWithAdapter( |
| 365 | request(` | 353 | request(` |
| 366 | const sum = await tools.calc.math.add_numbers({ a: 2, b: 5 }); | 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 | query: "mutation($value: Int!) { increment(value: $value) }", | 357 | query: "mutation($value: Int!) { increment(value: $value) }", |
| 370 | variables: { value: 3 }, | 358 | variables: { value: 3 }, |
| 371 | }); | 359 | }); |
| 372 | console.log("sum", sum.sum); | 360 | return { |
| 373 | console.log("hello", hello.data); | 361 | sum: sum.sum, |
| 374 | console.log("inc", inc.data.increment); | 362 | hello: hello.data.hello, |
| 375 | return sum.sum + inc.data.increment; | 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 | expect(result.status).toBe("completed"); | 370 | expect(result.status).toBe("completed"); |
| 381 | expect(result.stdout).toContain("sum 7"); | 371 | expect(result.result).toMatchObject({ sum: 7, inc: 4, total: 11 }); |
| 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); | ||
| 386 | } finally { | 372 | } finally { |
| 387 | server.stop(true); | 373 | server.stop(true); |
| 388 | } | 374 | } |
| @@ -2,7 +2,7 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | // NOTE: The Vercel sandbox runtime (vercel-sandbox-runtime.ts) contains a JS | 3 | // NOTE: The Vercel sandbox runtime (vercel-sandbox-runtime.ts) contains a JS |
| 4 | // string version of similar logic, built from sandbox-fragments.ts. Changes to | 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 | // loop, result mapping) should be mirrored there. | 6 | // loop, result mapping) should be mirrored there. |
| 7 | import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution-constants"; | 7 | import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution-constants"; |
| 8 | import { Result } from "better-result"; | 8 | import { Result } from "better-result"; |
| @@ -14,23 +14,25 @@ import type { · 5 unmodified lines | |||
| 14 | } from "../types"; | 14 | } from "../types"; |
| 15 | import { transpileForRuntime } from "./transpile"; | 15 | import { transpileForRuntime } from "./transpile"; |
| 16 | 16 | ||
| 17 | function formatArgs(args: unknown[]): string { | 17 | async function sleep(ms: number): Promise<void> { |
| 18 | return args | 18 | await new Promise((resolve) => setTimeout(resolve, ms)); |
| 19 | .map((value) => { | ||
| 20 | if (typeof value === "string") return value; | ||
| 21 | return Result.try(() => JSON.stringify(value)).unwrapOr(String(value)); | ||
| 22 | }) | ||
| 23 | .join(" "); | ||
| 24 | } | 19 | } |
| 25 | 20 | ||
| 26 | function fireAndForget(promise: void | Promise<void>): void { | 21 | function sanitizeExecutionResult(value: unknown): unknown { |
| 27 | if (promise && typeof promise === "object" && "then" in promise) { | 22 | if (value === undefined) { |
| 28 | void (promise as Promise<void>).catch(() => {}); | 23 | return undefined; |
| 29 | } | 24 | } |
| 30 | } | ||
| 31 | 25 | ||
| 32 | async function sleep(ms: number): Promise<void> { | 26 | const serialized = Result.try(() => JSON.stringify(value)); |
| 33 | await new Promise((resolve) => setTimeout(resolve, ms)); | 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 | function createToolsProxy( | 38 | function createToolsProxy( |
| @@ -110,40 +112,13 @@ export async function runCodeWithAdapter( · 73 unmodified lines | |||
| 110 | adapter: ExecutionAdapter, | 112 | adapter: ExecutionAdapter, |
| 111 | ): Promise<SandboxExecutionResult> { | 113 | ): Promise<SandboxExecutionResult> { |
| 112 | const startedAt = Date.now(); | 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 | const mkResult = ( | 116 | const mkResult = ( |
| 141 | status: SandboxExecutionResult["status"], | 117 | status: SandboxExecutionResult["status"], |
| 142 | opts?: { error?: string; exitCode?: number }, | 118 | opts?: { error?: string; exitCode?: number; result?: unknown }, |
| 143 | ): SandboxExecutionResult => ({ | 119 | ): SandboxExecutionResult => ({ |
| 144 | status, | 120 | status, |
| 145 | stdout: stdoutLines.join("\n"), | 121 | result: opts?.result, |
| 146 | stderr: stderrLines.join("\n"), | ||
| 147 | exitCode: opts?.exitCode, | 122 | exitCode: opts?.exitCode, |
| 148 | error: opts?.error, | 123 | error: opts?.error, |
| 149 | durationMs: Date.now() - startedAt, | 124 | durationMs: Date.now() - startedAt, |
| @@ -152,17 +127,16 @@ export async function runCodeWithAdapter( · 2 unmodified lines | |||
| 152 | // ── Transpile ────────────────────────────────────────────────────────── | 127 | // ── Transpile ────────────────────────────────────────────────────────── |
| 153 | const transpiled = transpileForRuntime(request.code); | 128 | const transpiled = transpileForRuntime(request.code); |
| 154 | if (transpiled.isErr()) { | 129 | if (transpiled.isErr()) { |
| 155 | appendStderr(transpiled.error.message); | ||
| 156 | return mkResult("failed", { error: transpiled.error.message }); | 130 | return mkResult("failed", { error: transpiled.error.message }); |
| 157 | } | 131 | } |
| 158 | 132 | ||
| 159 | // ── Sandbox setup ────────────────────────────────────────────────────── | 133 | // ── Sandbox setup ────────────────────────────────────────────────────── |
| 160 | const tools = createToolsProxy(adapter, request.taskId); | 134 | const tools = createToolsProxy(adapter, request.taskId); |
| 161 | const consoleProxy = { | 135 | const consoleProxy = { |
| 162 | log: (...args: unknown[]) => appendStdout(formatArgs(args)), | 136 | log: (..._args: unknown[]) => {}, |
| 163 | info: (...args: unknown[]) => appendStdout(formatArgs(args)), | 137 | info: (..._args: unknown[]) => {}, |
| 164 | warn: (...args: unknown[]) => appendStderr(formatArgs(args)), | 138 | warn: (..._args: unknown[]) => {}, |
| 165 | error: (...args: unknown[]) => appendStderr(formatArgs(args)), | 139 | error: (..._args: unknown[]) => {}, |
| 166 | }; | 140 | }; |
| 167 | 141 | ||
| 168 | const sandbox = Object.assign(Object.create(null), { | 142 | const sandbox = Object.assign(Object.create(null), { |
| @@ -206,13 +180,11 @@ export async function runCodeWithAdapter( · 37 unmodified lines | |||
| 206 | execution.error.cause, | 180 | execution.error.cause, |
| 207 | request, | 181 | request, |
| 208 | ); | 182 | ); |
| 209 | appendStderr(message); | ||
| 210 | return mkResult(status, { error: message }); | 183 | return mkResult(status, { error: message }); |
| 211 | } | 184 | } |
| 212 | 185 | ||
| 213 | if (execution.value !== undefined) { | 186 | return mkResult("completed", { |
| 214 | appendStdout(`result: ${formatArgs([execution.value])}`); | 187 | exitCode: 0, |
| 215 | } | 188 | result: sanitizeExecutionResult(execution.value), |
| 216 | 189 | }); | |
| 217 | return mkResult("completed", { exitCode: 0 }); | ||
| 218 | } | 190 | } |
| @@ -14,14 +14,9 @@ export class TranspileError extends TaggedError("TranspileError")<{ · 13 unmodified lines | |||
| 14 | // TypeScript module loader | 14 | // TypeScript module loader |
| 15 | // --------------------------------------------------------------------------- | 15 | // --------------------------------------------------------------------------- |
| 16 | 16 | ||
| 17 | let cachedTypeScript: typeof import("typescript") | null | undefined; | ||
| 18 | |||
| 19 | function getTypeScriptModule(): typeof import("typescript") | null { | 17 | function getTypeScriptModule(): typeof import("typescript") | null { |
| 20 | if (cachedTypeScript === undefined) { | 18 | const loaded = Result.try(() => require("typescript") as typeof import("typescript")); |
| 21 | const loaded = Result.try(() => require("typescript") as typeof import("typescript")); | 19 | return loaded.isOk() ? loaded.value : null; |
| 22 | cachedTypeScript = loaded.isOk() ? loaded.value : null; | ||
| 23 | } | ||
| 24 | return cachedTypeScript ?? null; | ||
| 25 | } | 20 | } |
| 26 | 21 | ||
| 27 | // --------------------------------------------------------------------------- | 22 | // --------------------------------------------------------------------------- |
| @@ -1,5 +1,6 @@ | |||
| 1 | import type { ToolDefinition } from "./types"; | 1 | import type { ToolDefinition } from "./types"; |
| 2 | import { | 2 | import { |
| 3 | compactArgKeysHint, | ||
| 3 | compactArgTypeHint, | 4 | compactArgTypeHint, |
| 4 | compactDescriptionLine, | 5 | compactDescriptionLine, |
| 5 | compactReturnTypeHint, | 6 | compactReturnTypeHint, |
| @@ -74,7 +75,7 @@ function buildExampleCall(entry: DiscoverIndexEntry): string { · 68 unmodified lines | |||
| 74 | 75 | ||
| 75 | const keys = entry.argPreviewKeys.length > 0 ? entry.argPreviewKeys : extractTopLevelTypeKeys(entry.argsType); | 76 | const keys = entry.argPreviewKeys.length > 0 ? entry.argPreviewKeys : extractTopLevelTypeKeys(entry.argsType); |
| 76 | if (keys.length > 0) { | 77 | if (keys.length > 0) { |
| 77 | const argsSnippet = keys.slice(0, 3) | 78 | const argsSnippet = keys.slice(0, 5) |
| 78 | .map((key) => `${key}: ${key.toLowerCase().includes("input") ? "{ /* ... */ }" : "..."}`) | 79 | .map((key) => `${key}: ${key.toLowerCase().includes("input") ? "{ /* ... */ }" : "..."}`) |
| 79 | .join(", "); | 80 | .join(", "); |
| 80 | return `await tools.${entry.path}({ ${argsSnippet} });`; | 81 | return `await tools.${entry.path}({ ${argsSnippet} });`; |
| @@ -214,7 +215,9 @@ function formatSignature(entry: DiscoverIndexEntry, depth: number, compact: bool · 133 unmodified lines | |||
| 214 | return "(input: ...): Promise<...>"; | 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 | const returns = compactReturnTypeHint(entry.returnsType); | 221 | const returns = compactReturnTypeHint(entry.returnsType); |
| 219 | 222 | ||
| 220 | if (depth === 1) { | 223 | if (depth === 1) { |
| @@ -526,6 +526,115 @@ function jsonSchemaTypeHintFallback( · 525 unmodified lines | |||
| 526 | return "unknown"; | 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 | async function connectMcp( | 638 | async function connectMcp( |
| 530 | url: string, | 639 | url: string, |
| 531 | queryParams: Record<string, string> | undefined, | 640 | queryParams: Record<string, string> | undefined, |
| @@ -908,7 +1017,7 @@ function compactOpenApiPaths( · 376 unmodified lines | |||
| 908 | return entry; | 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 | if (!Array.isArray(entries)) return []; | 1021 | if (!Array.isArray(entries)) return []; |
| 913 | return entries | 1022 | return entries |
| 914 | .map((entry) => resolveParam(asRecord(entry))) | 1023 | .map((entry) => resolveParam(asRecord(entry))) |
| @@ -982,32 +1091,12 @@ function compactOpenApiPaths( · 67 unmodified lines | |||
| 982 | const mergedParameters = normalizeParameters(operation.parameters).concat(sharedParameters); | 1091 | const mergedParameters = normalizeParameters(operation.parameters).concat(sharedParameters); |
| 983 | const hasInputSchema = | 1092 | const hasInputSchema = |
| 984 | mergedParameters.length > 0 || Object.keys(requestBodySchema).length > 0; | 1093 | mergedParameters.length > 0 || Object.keys(requestBodySchema).length > 0; |
| 985 | const combinedSchema: JsonSchema = { | 1094 | const combinedSchema = buildOpenApiInputSchema(mergedParameters, requestBodySchema); |
| 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 | }; | ||
| 1003 | compactOperation._argsTypeHint = hasInputSchema | 1095 | compactOperation._argsTypeHint = hasInputSchema |
| 1004 | ? jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas) | 1096 | ? jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas) |
| 1005 | : "{}"; | 1097 | : "{}"; |
| 1006 | compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas); | 1098 | compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas); |
| 1007 | const previewKeys = [ | 1099 | const previewKeys = buildOpenApiArgPreviewKeys(mergedParameters, requestBodySchema, compSchemas); |
| 1008 | ...mergedParameters.map((param) => String(param.name ?? "")).filter((name) => name.length > 0), | ||
| 1009 | ...Object.keys(asRecord(requestBodySchema.properties)), | ||
| 1010 | ]; | ||
| 1011 | if (previewKeys.length > 0) { | 1100 | if (previewKeys.length > 0) { |
| 1012 | compactOperation._argPreviewKeys = [...new Set(previewKeys)]; | 1101 | compactOperation._argPreviewKeys = [...new Set(previewKeys)]; |
| 1013 | } | 1102 | } |
| @@ -1254,29 +1343,13 @@ export function buildOpenApiToolsFromPrepared( · 240 unmodified lines | |||
| 1254 | if (Object.keys(responseSchema).length > 0) break; | 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 | const hasInputSchema = parameters.length > 0 || Object.keys(requestBodySchema).length > 0; | 1346 | const hasInputSchema = parameters.length > 0 || Object.keys(requestBodySchema).length > 0; |
| 1347 | const combinedSchema = buildOpenApiInputSchema(parameters, requestBodySchema); | ||
| 1272 | 1348 | ||
| 1273 | argsType = hasInputSchema ? jsonSchemaTypeHintFallback(combinedSchema) : "{}"; | 1349 | argsType = hasInputSchema ? jsonSchemaTypeHintFallback(combinedSchema) : "{}"; |
| 1274 | returnsType = responseTypeHintFromSchema(responseSchema, responseStatus); | 1350 | returnsType = responseTypeHintFromSchema(responseSchema, responseStatus); |
| 1275 | if (argPreviewKeys.length === 0) { | 1351 | if (argPreviewKeys.length === 0) { |
| 1276 | argPreviewKeys = [ | 1352 | argPreviewKeys = buildOpenApiArgPreviewKeys(parameters, requestBodySchema); |
| 1277 | ...parameters.map((param) => param.name), | ||
| 1278 | ...Object.keys(asRecord(requestBodySchema.properties)), | ||
| 1279 | ].filter((name, index, all) => name.length > 0 && all.indexOf(name) === index); | ||
| 1280 | } | 1353 | } |
| 1281 | } | 1354 | } |
| 1282 | 1355 | ||
| @@ -307,6 +307,54 @@ export interface operations { · 306 unmodified lines | |||
| 307 | expect(bad.errors.length).toBeGreaterThan(0); | 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 | test("OpenAPI helper types infer vendor JSON response payloads", () => { | 358 | test("OpenAPI helper types infer vendor JSON response payloads", () => { |
| 311 | const tools: ToolDescriptor[] = [ | 359 | const tools: ToolDescriptor[] = [ |
| 312 | { | 360 | { |
| @@ -464,4 +512,3 @@ export interface operations { · 151 unmodified lines | |||
| 464 | expect(result.errors).toHaveLength(0); | 512 | expect(result.errors).toHaveLength(0); |
| 465 | }); | 513 | }); |
| 466 | }); | 514 | }); |
| 467 | |||
| @@ -20,14 +20,9 @@ import type { ToolDescriptor } from "./types"; · 19 unmodified lines | |||
| 20 | // Tool declarations generation | 20 | // Tool declarations generation |
| 21 | // --------------------------------------------------------------------------- | 21 | // --------------------------------------------------------------------------- |
| 22 | 22 | ||
| 23 | let cachedTypeScript: typeof import("typescript") | null | undefined; | ||
| 24 | |||
| 25 | function getTypeScriptModule(): typeof import("typescript") | null { | 23 | function getTypeScriptModule(): typeof import("typescript") | null { |
| 26 | if (cachedTypeScript === undefined) { | 24 | const loaded = Result.try(() => require("typescript") as typeof import("typescript")); |
| 27 | const loaded = Result.try(() => require("typescript") as typeof import("typescript")); | 25 | return loaded.isOk() ? loaded.value : null; |
| 28 | cachedTypeScript = loaded.isOk() ? loaded.value : null; | ||
| 29 | } | ||
| 30 | return cachedTypeScript ?? null; | ||
| 31 | } | 26 | } |
| 32 | 27 | ||
| 33 | function isValidTypeExpression(typeExpression: string): boolean { | 28 | function isValidTypeExpression(typeExpression: string): boolean { |
| @@ -175,7 +170,12 @@ export function generateToolDeclarations( · 141 unmodified lines | |||
| 175 | const tool = child.tool; | 170 | const tool = child.tool; |
| 176 | if (tool.operationId && tool.source && dtsSources.has(tool.source)) { | 171 | if (tool.operationId && tool.source && dtsSources.has(tool.source)) { |
| 177 | const opKey = JSON.stringify(tool.operationId); | 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 | } else { | 179 | } else { |
| 180 | const strictArgsType = tool.strictArgsType?.trim(); | 180 | const strictArgsType = tool.strictArgsType?.trim(); |
| 181 | const strictReturnsType = tool.strictReturnsType?.trim(); | 181 | const strictReturnsType = tool.strictReturnsType?.trim(); |
| @@ -7,10 +7,19 @@ export type ApprovalStatus = "pending" | "approved" | "denied"; · 6 unmodified lines | |||
| 7 | export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied"; | 7 | export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied"; |
| 8 | export type PolicyDecision = "allow" | "require_approval" | "deny"; | 8 | export type PolicyDecision = "allow" | "require_approval" | "deny"; |
| 9 | export type CredentialScope = "workspace" | "actor"; | 9 | export type CredentialScope = "workspace" | "actor"; |
| 10 | export type CredentialProvider = "managed" | "workos-vault"; | 10 | export type CredentialProvider = "local-convex" | "workos-vault"; |
| 11 | export type ToolApprovalMode = "auto" | "required"; | 11 | export type ToolApprovalMode = "auto" | "required"; |
| 12 | export type ToolSourceType = "mcp" | "openapi" | "graphql"; | 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 | export interface TaskRecord { | 23 | export interface TaskRecord { |
| 15 | id: string; | 24 | id: string; |
| 16 | code: string; | 25 | code: string; |
| @@ -26,8 +35,7 @@ export interface TaskRecord { · 9 unmodified lines | |||
| 26 | startedAt?: number; | 35 | startedAt?: number; |
| 27 | completedAt?: number; | 36 | completedAt?: number; |
| 28 | error?: string; | 37 | error?: string; |
| 29 | stdout?: string; | 38 | result?: unknown; |
| 30 | stderr?: string; | ||
| 31 | exitCode?: number; | 39 | exitCode?: number; |
| 32 | } | 40 | } |
| 33 | 41 | ||
| @@ -61,10 +69,8 @@ export interface ToolCallRecord { · 27 unmodified lines | |||
| 61 | callId: string; | 69 | callId: string; |
| 62 | workspaceId: Id<"workspaces">; | 70 | workspaceId: Id<"workspaces">; |
| 63 | toolPath: string; | 71 | toolPath: string; |
| 64 | input: unknown; | ||
| 65 | status: ToolCallStatus; | 72 | status: ToolCallStatus; |
| 66 | approvalId?: string; | 73 | approvalId?: string; |
| 67 | output?: unknown; | ||
| 68 | error?: string; | 74 | error?: string; |
| 69 | createdAt: number; | 75 | createdAt: number; |
| 70 | updatedAt: number; | 76 | updatedAt: number; |
| @@ -85,10 +91,13 @@ export interface AccessPolicyRecord { · 14 unmodified lines | |||
| 85 | 91 | ||
| 86 | export interface CredentialRecord { | 92 | export interface CredentialRecord { |
| 87 | id: string; | 93 | id: string; |
| 94 | bindingId?: string; | ||
| 88 | workspaceId: Id<"workspaces">; | 95 | workspaceId: Id<"workspaces">; |
| 89 | sourceKey: string; | 96 | sourceKey: string; |
| 90 | scope: CredentialScope; | 97 | scope: CredentialScope; |
| 91 | actorId?: string; | 98 | actorId?: string; |
| 99 | overridesJson?: Record<string, unknown>; | ||
| 100 | boundAuthFingerprint?: string; | ||
| 92 | provider: CredentialProvider; | 101 | provider: CredentialProvider; |
| 93 | secretJson: Record<string, unknown>; | 102 | secretJson: Record<string, unknown>; |
| 94 | createdAt: number; | 103 | createdAt: number; |
| @@ -167,8 +176,7 @@ export interface SandboxExecutionRequest { · 72 unmodified lines | |||
| 167 | 176 | ||
| 168 | export interface SandboxExecutionResult { | 177 | export interface SandboxExecutionResult { |
| 169 | status: Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">; | 178 | status: Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">; |
| 170 | stdout: string; | 179 | result?: unknown; |
| 171 | stderr: string; | ||
| 172 | exitCode?: number; | 180 | exitCode?: number; |
| 173 | error?: string; | 181 | error?: string; |
| 174 | durationMs: number; | 182 | durationMs: number; |
| @@ -193,18 +201,8 @@ export type ToolCallResult = · 18 unmodified lines | |||
| 193 | | { ok: false; kind: "denied"; error: string } | 201 | | { ok: false; kind: "denied"; error: string } |
| 194 | | { ok: false; kind: "failed"; error: string }; | 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 | export interface ExecutionAdapter { | 204 | export interface ExecutionAdapter { |
| 206 | invokeTool(call: ToolCallRequest): Promise<ToolCallResult>; | 205 | invokeTool(call: ToolCallRequest): Promise<ToolCallResult>; |
| 207 | emitOutput(event: RuntimeOutputEvent): void | Promise<void>; | ||
| 208 | } | 206 | } |
| 209 | 207 | ||
| 210 | export interface SandboxRuntime { | 208 | export interface SandboxRuntime { |
| @@ -17,9 +17,8 @@ · 16 unmodified lines | |||
| 17 | * (passed as a loopback service binding via `ctx.exports`) which invokes | 17 | * (passed as a loopback service binding via `ctx.exports`) which invokes |
| 18 | * Convex callback RPC functions to resolve them. | 18 | * Convex callback RPC functions to resolve them. |
| 19 | * | 19 | * |
| 20 | * 4. Console output is buffered in the harness and returned in the response. | 20 | * 4. Console output is intentionally discarded. Only explicit `return` values |
| 21 | * Output lines are also streamed back to Convex in real-time via the | 21 | * are included in terminal run results. |
| 22 | * ToolBridge binding. | ||
| 23 | * | 22 | * |
| 24 | * 5. `/v1/runs` returns an accepted dispatch response immediately. Terminal | 23 | * 5. `/v1/runs` returns an accepted dispatch response immediately. Terminal |
| 25 | * result status is reported back to Convex through callback RPC. | 24 | * result status is reported back to Convex through callback RPC. |
| @@ -89,8 +88,7 @@ interface RunRequest { · 63 unmodified lines | |||
| 89 | 88 | ||
| 90 | interface RunResult { | 89 | interface RunResult { |
| 91 | status: "completed" | "failed" | "timed_out" | "denied"; | 90 | status: "completed" | "failed" | "timed_out" | "denied"; |
| 92 | stdout: string; | 91 | result?: unknown; |
| 93 | stderr: string; | ||
| 94 | error?: string; | 92 | error?: string; |
| 95 | exitCode?: number; | 93 | exitCode?: number; |
| 96 | } | 94 | } |
| @@ -143,8 +141,6 @@ function timingSafeEqual(a: string, b: string): boolean { · 46 unmodified lines | |||
| 143 | 141 | ||
| 144 | const failedResult = (error: string): RunResult => ({ | 142 | const failedResult = (error: string): RunResult => ({ |
| 145 | status: "failed", | 143 | status: "failed", |
| 146 | stdout: "", | ||
| 147 | stderr: "", | ||
| 148 | error, | 144 | error, |
| 149 | }); | 145 | }); |
| 150 | 146 | ||
| @@ -198,21 +194,6 @@ export class ToolBridge extends WorkerEntrypoint<Env> { · 47 unmodified lines | |||
| 198 | 194 | ||
| 199 | return response.value as ToolCallResult; | 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 | // ── Sandbox Harness ────────────────────────────────────────────────────────── | 199 | // ── Sandbox Harness ────────────────────────────────────────────────────────── |
| @@ -285,8 +266,6 @@ async function executeSandboxRun(request: RunRequest, ctx: ExecutionContext, env · 66 unmodified lines | |||
| 285 | if (cause instanceof DOMException && cause.name === "AbortError") { | 266 | if (cause instanceof DOMException && cause.name === "AbortError") { |
| 286 | return { | 267 | return { |
| 287 | status: "timed_out", | 268 | status: "timed_out", |
| 288 | stdout: "", | ||
| 289 | stderr: "", | ||
| 290 | error: `Execution timed out after ${timeoutMs}ms`, | 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 | internalSecret: request.callback.internalSecret, | 291 | internalSecret: request.callback.internalSecret, |
| 313 | runId: request.taskId, | 292 | runId: request.taskId, |
| 314 | status: result.status, | 293 | status: result.status, |
| 315 | stdout: result.stdout, | 294 | result: result.result, |
| 316 | stderr: result.stderr, | ||
| 317 | exitCode: result.exitCode, | 295 | exitCode: result.exitCode, |
| 318 | error: result.error, | 296 | error: result.error, |
| 319 | durationMs, | 297 | durationMs, |
| @@ -3,19 +3,6 @@ import { run } from "./user-code.js"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:"; | 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 | async function sleep(ms) { | 6 | async function sleep(ms) { |
| 20 | await new Promise((resolve) => setTimeout(resolve, ms)); | 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 { | 38 | function sanitizeExecutionResult(value) { |
| 52 | async fetch(req, env, ctx) { | 39 | if (value === undefined) return undefined; |
| 53 | const stdoutLines = []; | 40 | try { |
| 54 | const stderrLines = []; | 41 | const serialized = JSON.stringify(value); |
| 55 | 42 | if (serialized === undefined) return null; | |
| 56 | const appendStdout = (line) => { | 43 | return JSON.parse(serialized); |
| 57 | stdoutLines.push(line); | 44 | } catch { |
| 58 | ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stdout", line)); | 45 | return String(value); |
| 59 | }; | 46 | } |
| 60 | const appendStderr = (line) => { | 47 | } |
| 61 | stderrLines.push(line); | ||
| 62 | ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stderr", line)); | ||
| 63 | }; | ||
| 64 | 48 | ||
| 49 | export default { | ||
| 50 | async fetch(req, env, _ctx) { | ||
| 65 | const tools = createToolsProxy(env.TOOL_BRIDGE); | 51 | const tools = createToolsProxy(env.TOOL_BRIDGE); |
| 66 | const console = { | 52 | const console = { |
| 67 | log: (...args) => appendStdout(formatArgs(args)), | 53 | log: (..._args) => {}, |
| 68 | info: (...args) => appendStdout(formatArgs(args)), | 54 | info: (..._args) => {}, |
| 69 | warn: (...args) => appendStderr(formatArgs(args)), | 55 | warn: (..._args) => {}, |
| 70 | error: (...args) => appendStderr(formatArgs(args)), | 56 | error: (..._args) => {}, |
| 71 | }; | 57 | }; |
| 72 | 58 | ||
| 73 | try { | 59 | try { |
| 74 | const value = await run(tools, console); | 60 | const value = await run(tools, console); |
| 75 | 61 | ||
| 76 | if (value !== undefined) { | ||
| 77 | appendStdout("result: " + formatArgs([value])); | ||
| 78 | } | ||
| 79 | |||
| 80 | return _ResponseJson({ | 62 | return _ResponseJson({ |
| 81 | status: "completed", | 63 | status: "completed", |
| 82 | stdout: stdoutLines.join("\n"), | 64 | result: sanitizeExecutionResult(value), |
| 83 | stderr: stderrLines.join("\n"), | ||
| 84 | exitCode: 0, | 65 | exitCode: 0, |
| 85 | }); | 66 | }); |
| 86 | } catch (error) { | 67 | } catch (error) { |
| @@ -88,19 +69,13 @@ export default { · 1 unmodified line | |||
| 88 | error instanceof Error ? error.message : String(error); | 69 | error instanceof Error ? error.message : String(error); |
| 89 | if (message.startsWith(APPROVAL_DENIED_PREFIX)) { | 70 | if (message.startsWith(APPROVAL_DENIED_PREFIX)) { |
| 90 | const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim(); | 71 | const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim(); |
| 91 | appendStderr(denied); | ||
| 92 | return _ResponseJson({ | 72 | return _ResponseJson({ |
| 93 | status: "denied", | 73 | status: "denied", |
| 94 | stdout: stdoutLines.join("\n"), | ||
| 95 | stderr: stderrLines.join("\n"), | ||
| 96 | error: denied, | 74 | error: denied, |
| 97 | }); | 75 | }); |
| 98 | } | 76 | } |
| 99 | appendStderr(message); | ||
| 100 | return _ResponseJson({ | 77 | return _ResponseJson({ |
| 101 | status: "failed", | 78 | status: "failed", |
| 102 | stdout: stdoutLines.join("\n"), | ||
| 103 | stderr: stderrLines.join("\n"), | ||
| 104 | error: message, | 79 | error: message, |
| 105 | }); | 80 | }); |
| 106 | } | 81 | } |