History

Refactor executor services and add anonymous MCP OAuth fallback

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.

Rhys Sullivan committed 9 months ago

Commit 2c6905b1 parent 0d9954358 files · +3790 −2296Browse files

Unified · Split

58 files
  1. .env.example +4 −0
  2. executor/.gitignore +2 −0
  3. executor/README.md +6 −2
  4. executor/packages/convex/_generated/api.d.ts +83 −34
  5. executor/packages/convex/anonymousOauth.ts +58 −0
  6. executor/packages/convex/auth.ts +3 −10
  7. executor/packages/convex/credentialsNode.ts +88 −33
  8. executor/packages/convex/database.test.ts +52 −8
  9. executor/packages/convex/database.ts +246 −37
  10. executor/packages/convex/executor.ts +3 −28
  11. executor/packages/convex/executorNode.ts +39 −1277
  12. executor/packages/convex/executor_node/actor_auth.ts +29 −0
  13. executor/packages/convex/executor_node/events.ts +19 −0
  14. executor/packages/convex/executor_node/external_tool_call.ts +64 −0
  15. executor/packages/convex/executor_node/policy.ts +75 −0
  16. executor/packages/convex/executor_node/task_runner.ts +175 −0
  17. executor/packages/convex/executor_node/tool_descriptors.ts +103 −0
  18. executor/packages/convex/executor_node/tool_invocation.ts +374 −0
  19. executor/packages/convex/executor_node/tool_paths.ts +105 −0
  20. executor/packages/convex/executor_node/tool_source_loading.ts +194 −0
  21. executor/packages/convex/executor_node/workspace_tools.ts +304 −0
  22. executor/packages/convex/http-anonymous-oauth.e2e.test.ts +199 −0
  23. executor/packages/convex/http.ts +16 −287
  24. executor/packages/convex/http/action_oauth_storage.ts +136 −0
  25. executor/packages/convex/http/mcp_auth.ts +239 −0
  26. executor/packages/convex/http/mcp_handler.ts +132 −0
  27. executor/packages/convex/http/mcp_service.ts +85 −0
  28. executor/packages/convex/http/oauth_handlers.ts +213 −0
  29. executor/packages/convex/mcpNode.ts +2 −2
  30. executor/packages/convex/runtimeCallbacks.ts +3 −34
  31. executor/packages/convex/schema.ts +29 −7
  32. executor/packages/convex/workspace.ts +2 −1
  33. executor/packages/core/src/adapters/in-process-execution-adapter.test.ts +0 −29
  34. executor/packages/core/src/adapters/in-process-execution-adapter.ts +0 −9
  35. executor/packages/core/src/anonymous-oauth.test.ts +89 −6
  36. executor/packages/core/src/anonymous-oauth.ts +116 −49
  37. executor/packages/core/src/convex-oauth-storage.ts +67 −3
  38. executor/packages/core/src/credential-providers.test.ts +4 −4
  39. executor/packages/core/src/credential-providers.ts +2 −2
  40. executor/packages/core/src/dts-loader.test.ts +20 −19
  41. executor/packages/core/src/dts-loader.ts +2 −32
  42. executor/packages/core/src/identity.ts +2 −2
  43. executor/packages/core/src/mcp-gateway-oauth-sdk.test.ts +6 −4
  44. executor/packages/core/src/mcp-gateway-oauth.test.ts +9 −3
  45. executor/packages/core/src/mcp-server.test.ts +9 −14
  46. executor/packages/core/src/mcp-server.ts +20 −118
  47. executor/packages/core/src/openapi-cache.test.ts +93 −0
  48. executor/packages/core/src/runtimes/cloudflare-worker-loader-runtime.ts +2 −2
  49. executor/packages/core/src/runtimes/runtime-core.test.ts +28 −42
  50. executor/packages/core/src/runtimes/runtime-core.ts +26 −54
  51. executor/packages/core/src/runtimes/transpile.ts +2 −7
  52. executor/packages/core/src/tool-discovery.ts +5 −2
  53. executor/packages/core/src/tool-sources.ts +114 −41
  54. executor/packages/core/src/typechecker.test.ts +48 −1
  55. executor/packages/core/src/typechecker.ts +8 −8
  56. executor/packages/core/src/types.ts +15 −17
  57. executor/packages/runner-sandbox-host/src/index.ts +4 −26
  58. executor/packages/runner-sandbox-host/src/isolate/harness.isolate.js +17 −42
.env.example+4−0
@@ -26,6 +26,10 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 25 unmodified lines
26# EXECUTOR_BACKEND_SITE_PORT=541126# EXECUTOR_BACKEND_SITE_PORT=5411
27# EXECUTOR_WEB_PORT=531227# 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
executor/.gitignore+2−0
@@ -10,3 +10,5 @@ dist · 9 unmodified lines
10.vercel10.vercel
11.env*.local11.env*.local
12.convex12.convex
13**/.wrangler
14**/.dev.vars
executor/README.md+6−2
@@ -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
127MCP bearer-token verification is enabled when `MCP_AUTHORIZATION_SERVER` (or `MCP_AUTHORIZATION_SERVER_URL`) is configured.130MCP 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 Reference132## 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`
executor/packages/convex/_generated/api.d.ts+83−34
@@ -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 any54 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 any71 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 any333 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 any558 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 any722 any
@@ -688,6 +727,12 @@ export declare const internal: { · 4 unmodified lines
688 { taskId: string },727 { taskId: string },
689 any728 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 any768 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 any863 any
815 >;864 >;
executor/packages/convex/anonymousOauth.ts+58−0
@@ -83,3 +83,61 @@ export const getClient = query({ · 82 unmodified lines
83 );83 );
84 },84 },
85});85});
86
87// ── Authorization Codes ─────────────────────────────────────────────────────
88
89export 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
109export 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
122export 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
135export 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});
executor/packages/convex/auth.ts+3−10
@@ -3,6 +3,7 @@ import { components, internal } from "./_generated/api"; · 2 unmodified lines
3import type { DataModel, Doc, Id } from "./_generated/dataModel";3import type { DataModel, Doc, Id } from "./_generated/dataModel";
4import type { MutationCtx } from "./_generated/server";4import type { MutationCtx } from "./_generated/server";
5import { internalMutation, mutation } from "./_generated/server";5import { internalMutation, mutation } from "./_generated/server";
6import { slugify } from "../core/src/identity";
6import { ensureUniqueSlug } from "../core/src/slug";7import { ensureUniqueSlug } from "../core/src/slug";
7 8
8type DbCtx = Pick<MutationCtx, "db">;9type 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
51function 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
59function titleCaseWords(input: string): string {52function titleCaseWords(input: string): string {
60 return input53 return input
61 .split(" ")54 .split(" ")
@@ -155,7 +148,7 @@ async function getFirstWorkspaceByOrganizationId(ctx: DbCtx, organizationId: Id< · 93 unmodified lines
155}148}
156 149
157async function ensureUniqueOrganizationSlug(ctx: DbCtx, baseName: string): Promise<string> {150async 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.db153 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)}`,
executor/packages/convex/credentialsNode.ts+88−33
@@ -6,7 +6,9 @@ import { internal } from "./_generated/api"; · 5 unmodified lines
6import { action } from "./_generated/server";6import { action } from "./_generated/server";
7 7
8const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));8const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
9const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault"));9const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault"));
10
11type SecretBackend = "local-convex" | "workos-vault";
10 12
11function asRecord(value: unknown): Record<string, unknown> {13function 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
26function 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
24function extractObjectId(secretJson: Record<string, unknown>): string | null {37function 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
32function stripObjectRef(secretJson: Record<string, unknown>): Record<string, unknown> {45function 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
37function 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
41function buildVaultObjectName(args: {65function 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
56function workosClient(): WorkOS {80function 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});
executor/packages/convex/database.test.ts+52−8
@@ -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
206test("upsertCredential defaults provider to managed", async () => {205test("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
219test("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});
executor/packages/convex/database.ts+246−37
@@ -2,6 +2,7 @@ import { v } from "convex/values"; · 1 unmodified line
2import type { Doc } from "./_generated/dataModel";2import type { Doc } from "./_generated/dataModel";
3import { type MutationCtx, type QueryCtx } from "./_generated/server";3import { type MutationCtx, type QueryCtx } from "./_generated/server";
4import { internalMutation, internalQuery } from "./_generated/server";4import { internalMutation, internalQuery } from "./_generated/server";
5import { slugify } from "../core/src/identity";
5import { ensureUniqueSlug } from "../core/src/slug";6import { ensureUniqueSlug } from "../core/src/slug";
6import type { TaskStatus } from "../core/src/types";7import type { TaskStatus } from "../core/src/types";
7 8
@@ -24,21 +25,13 @@ const terminalToolCallStatusValidator = v.union( · 16 unmodified lines
24const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));25const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
25const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));26const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
26const credentialProviderValidator = v.union(27const credentialProviderValidator = v.union(
27 v.literal("managed"),28 v.literal("local-convex"),
28 v.literal("workos-vault"),29 v.literal("workos-vault"),
29);30);
30const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));31const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
31 32
32function 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
40async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> {33async 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.db36 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
92function 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
104function 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
116function 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
99function mapTask(doc: Doc<"tasks">) {132function 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
166function mapCredential(doc: Doc<"sourceCredentials">) {196function 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
956async 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
927export const upsertCredential = internalMutation({983export 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.db1000 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
1005export const listCredentialProviders = internalQuery({1112export 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.db1187 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
1450export 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
1477export 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
1507export 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
1525export 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});
executor/packages/convex/executor.ts+3−28
@@ -222,35 +222,11 @@ export const resolveApprovalInternal = internalMutation({ · 221 unmodified lines
222 },222 },
223});223});
224 224
225export 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
248export const completeRuntimeRun = internalMutation({225export 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 });
executor/packages/convex/executorNode.ts+39−1277
@@ -1,1062 +1,22 @@
1"use node";1"use node";
2 2
3import { v } from "convex/values";3import { v } from "convex/values";
4import { internal } from "./_generated/api";
5import type { Id } from "./_generated/dataModel";
6import { action, internalAction } from "./_generated/server";4import { action, internalAction } from "./_generated/server";
7import type { ActionCtx } from "./_generated/server";
8import { InProcessExecutionAdapter } from "../core/src/adapters/in-process-execution-adapter";
9import { resolveCredentialPayload } from "../core/src/credential-providers";
10import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../core/src/execution-constants";
11import { actorIdForAccount } from "../core/src/identity";
12import { dispatchCodeWithCloudflareWorkerLoader } from "../core/src/runtimes/cloudflare-worker-loader-runtime";
13import {
14 CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
15 isCloudflareWorkerLoaderConfigured,
16 isKnownRuntimeId,
17 LOCAL_BUN_RUNTIME_ID,
18} from "../core/src/runtimes/runtime-catalog";
19import { runCodeWithAdapter } from "../core/src/runtimes/runtime-core";
20import { createDiscoverTool } from "../core/src/tool-discovery";
21import {
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";
36import { DEFAULT_TOOLS } from "../core/src/tools";
37import type {5import 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";
53import { asPayload, describeError } from "../core/src/utils";11import { requireCanonicalActor } from "./executor_node/actor_auth";
54 12import {
55function createApprovalId(): string {13 listToolsForContext,
56 return `approval_${crypto.randomUUID()}`;14 listToolsWithWarningsForContext,
57}15 loadDtsUrls,
58 16 loadWorkspaceDtsStorageIds,
59function matchesToolPath(pattern: string, toolPath: string): boolean {17} from "./executor_node/workspace_tools";
60 const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");18import { runQueuedTask } from "./executor_node/task_runner";
61 const regex = new RegExp(`^${escaped}$`);19import { handleExternalToolCallRequest } from "./executor_node/external_tool_call";
62 return regex.test(toolPath);
63}
64
65function 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
74function 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
82function normalizeToolPathSegment(segment: string): string {
83 return segment.toLowerCase().replace(/[^a-z0-9]/g, "");
84}
85
86function normalizeToolPath(path: string): string {
87 return path
88 .split(".")
89 .filter(Boolean)
90 .map((segment) => normalizeToolPathSegment(segment))
91 .join(".");
92}
93
94function 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
119function 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
144function 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
186function 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
274const baseTools = new Map<string, ToolDefinition>(DEFAULT_TOOLS.map((tool) => [tool.path, tool]));
275interface DtsStorageEntry {
276 sourceKey: string;
277 storageId: Id<"_storage">;
278}
279
280const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
281
282/** Cache version — bump when PreparedOpenApiSpec shape changes. */
283const OPENAPI_CACHE_VERSION = "v15";
284
285async 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 */
309async 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
358async 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
391interface WorkspaceToolsResult {
392 tools: Map<string, ToolDefinition>;
393 warnings: string[];
394 dtsStorageIds: DtsStorageEntry[];
395}
396
397async 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
532function 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
559function 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
571function 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
588function 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
649function 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
667async 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
680async 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
716function 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
727async 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
774function 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
830async 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
1061export const listToolsWithWarnings = action({21export 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
52export 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
1098export const listToolsInternal = internalAction({75export 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
1184export const runTask = internalAction({116export 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});
executor/packages/convex/executor_node/actor_auth.ts+29−0
@@ -0,0 +1,29 @@
1"use node";
2
3import type { ActionCtx } from "../_generated/server";
4import type { Id } from "../_generated/dataModel";
5import { internal } from "../_generated/api";
6import { actorIdForAccount } from "../../core/src/identity";
7
8export 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}
executor/packages/convex/executor_node/events.ts+19−0
@@ -0,0 +1,19 @@
1"use node";
2
3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";
5
6export 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}
executor/packages/convex/executor_node/external_tool_call.ts+64−0
@@ -0,0 +1,64 @@
1"use node";
2
3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";
5import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants";
6import type { TaskRecord, ToolCallResult } from "../../core/src/types";
7import { describeError } from "../../core/src/utils";
8import { invokeTool } from "./tool_invocation";
9
10export 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}
executor/packages/convex/executor_node/policy.ts+75−0
@@ -0,0 +1,75 @@
1"use node";
2
3import type {
4 AccessPolicyRecord,
5 PolicyDecision,
6 TaskRecord,
7 ToolDefinition,
8} from "../../core/src/types";
9
10function 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
16function 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
25export 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
50export 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
66export 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}
executor/packages/convex/executor_node/task_runner.ts+175−0
@@ -0,0 +1,175 @@
1"use node";
2
3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";
5import { InProcessExecutionAdapter } from "../../core/src/adapters/in-process-execution-adapter";
6import { APPROVAL_DENIED_PREFIX } from "../../core/src/execution-constants";
7import { dispatchCodeWithCloudflareWorkerLoader } from "../../core/src/runtimes/cloudflare-worker-loader-runtime";
8import {
9 CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
10 isCloudflareWorkerLoaderConfigured,
11 isKnownRuntimeId,
12} from "../../core/src/runtimes/runtime-catalog";
13import { runCodeWithAdapter } from "../../core/src/runtimes/runtime-core";
14import type { TaskRecord } from "../../core/src/types";
15import { describeError } from "../../core/src/utils";
16import { publishTaskEvent } from "./events";
17import { invokeTool } from "./tool_invocation";
18
19export 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}
executor/packages/convex/executor_node/tool_descriptors.ts+103−0
@@ -0,0 +1,103 @@
1"use node";
2
3import type {
4 AccessPolicyRecord,
5 OpenApiSourceQuality,
6 ToolDefinition,
7 ToolDescriptor,
8} from "../../core/src/types";
9import { getDecisionForContext } from "./policy";
10
11function 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
26export 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
87export 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}
executor/packages/convex/executor_node/tool_invocation.ts+374−0
@@ -0,0 +1,374 @@
1"use node";
2
3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";
5import { resolveCredentialPayload } from "../../core/src/credential-providers";
6import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants";
7import { parseGraphqlOperationPaths } from "../../core/src/tool-sources";
8import 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";
20import { asPayload, describeError } from "../../core/src/utils";
21import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy";
22import { resolveAliasedToolPath, suggestToolPaths } from "./tool_paths";
23import { baseTools, getWorkspaceTools } from "./workspace_tools";
24import { publishTaskEvent } from "./events";
25
26function createApprovalId(): string {
27 return `approval_${crypto.randomUUID()}`;
28}
29
30async 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
92function 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
148export 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}
executor/packages/convex/executor_node/tool_paths.ts+105−0
@@ -0,0 +1,105 @@
1"use node";
2
3import type { ToolDefinition } from "../../core/src/types";
4
5function normalizeToolPathSegment(segment: string): string {
6 return segment.toLowerCase().replace(/[^a-z0-9]/g, "");
7}
8
9function normalizeToolPath(path: string): string {
10 return path
11 .split(".")
12 .filter(Boolean)
13 .map((segment) => normalizeToolPathSegment(segment))
14 .join(".");
15}
16
17function 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
42export 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
67export 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}
executor/packages/convex/executor_node/tool_source_loading.ts+194−0
@@ -0,0 +1,194 @@
1"use node";
2
3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";
5import {
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";
16import type { ToolSourceRecord } from "../../core/src/types";
17
18const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
19
20/** Cache version - bump when PreparedOpenApiSpec shape changes. */
21const OPENAPI_CACHE_VERSION = "v15";
22
23export 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
31export 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
119async 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
163export 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}
executor/packages/convex/executor_node/workspace_tools.ts+304−0
@@ -0,0 +1,304 @@
1"use node";
2
3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";
5import type { Id } from "../_generated/dataModel";
6import { createDiscoverTool } from "../../core/src/tool-discovery";
7import {
8 materializeCompiledToolSource,
9 materializeWorkspaceSnapshot,
10 type CompiledToolSourceArtifact,
11 type ExternalToolSourceConfig,
12 type WorkspaceToolSnapshot,
13} from "../../core/src/tool-sources";
14import { DEFAULT_TOOLS } from "../../core/src/tools";
15import type {
16 AccessPolicyRecord,
17 OpenApiSourceQuality,
18 SourceAuthProfile,
19 ToolDefinition,
20 ToolDescriptor,
21} from "../../core/src/types";
22import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";
23import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
24
25const baseTools = new Map<string, ToolDefinition>(DEFAULT_TOOLS.map((tool) => [tool.path, tool]));
26
27export interface DtsStorageEntry {
28 sourceKey: string;
29 storageId: Id<"_storage">;
30}
31
32export interface WorkspaceToolsResult {
33 tools: Map<string, ToolDefinition>;
34 warnings: string[];
35 dtsStorageIds: DtsStorageEntry[];
36}
37
38export interface WorkspaceToolInventory {
39 tools: ToolDescriptor[];
40 warnings: string[];
41 dtsStorageIds: DtsStorageEntry[];
42 sourceQuality: Record<string, OpenApiSourceQuality>;
43 sourceAuthProfiles: Record<string, SourceAuthProfile>;
44}
45
46function 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
78export 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
206export 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
228export 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
236export 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
258export 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
280export 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
304export { baseTools };
executor/packages/convex/http-anonymous-oauth.e2e.test.ts+199−0
@@ -0,0 +1,199 @@
1import { expect, test } from "bun:test";
2import { convexTest } from "convex-test";
3import { internal } from "./_generated/api";
4import schema from "./schema";
5import { computeS256Challenge } from "../core/src/anonymous-oauth";
6
7function 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
23function 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
33async 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
42test("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
172test("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});
executor/packages/convex/http.ts+16−287
@@ -1,295 +1,19 @@
1import { registerRoutes as registerStripeRoutes } from "@convex-dev/stripe";1import { registerRoutes as registerStripeRoutes } from "@convex-dev/stripe";
2import { createRemoteJWKSet, jwtVerify } from "jose";
3import { httpRouter } from "convex/server";2import { httpRouter } from "convex/server";
4import { components, internal } from "./_generated/api";3import { components } from "./_generated/api";
5import { httpAction } from "./_generated/server";
6import { authKit } from "./auth";4import { authKit } from "./auth";
7import { handleMcpRequest, type McpWorkspaceContext } from "../core/src/mcp-server";5import { mcpHandler } from "./http/mcp_handler";
8import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescriptor } from "../core/src/types";6import {
9import type { Id } from "./_generated/dataModel";7 oauthAuthorizationServerHandler,
8 oauthAuthorizeHandler,
9 oauthJwksHandler,
10 oauthProtectedResourceHandler,
11 oauthRegisterHandler,
12 oauthTokenHandler,
13} from "./http/oauth_handlers";
10 14
11const http = httpRouter();15const http = httpRouter();
12 16
13function 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
21function 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
43function 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
50function resourceMetadataUrl(request: Request): string {
51 const url = new URL(request.url);
52 return `${url.origin}/.well-known/oauth-protected-resource`;
53}
54
55function 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
73async 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 */
106function parseWorkspaceId(raw: string): Id<"workspaces"> {
107 return raw as Id<"workspaces">;
108}
109
110function 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
123const 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
259const 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
273const 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
293authKit.registerRoutes(http);17authKit.registerRoutes(http);
294registerStripeRoutes(http, components.stripe, {18registerStripeRoutes(http, components.stripe, {
295 webhookPath: "/stripe/webhook",19 webhookPath: "/stripe/webhook",
@@ -298,7 +22,12 @@ registerStripeRoutes(http, components.stripe, { · 2 unmodified lines
298http.route({ path: "/mcp", method: "POST", handler: mcpHandler });22http.route({ path: "/mcp", method: "POST", handler: mcpHandler });
299http.route({ path: "/mcp", method: "GET", handler: mcpHandler });23http.route({ path: "/mcp", method: "GET", handler: mcpHandler });
300http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler });24http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler });
25
301http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler });26http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler });
302http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerProxyHandler });27http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerHandler });
28http.route({ path: "/oauth2/jwks", method: "GET", handler: oauthJwksHandler });
29http.route({ path: "/register", method: "POST", handler: oauthRegisterHandler });
30http.route({ path: "/authorize", method: "GET", handler: oauthAuthorizeHandler });
31http.route({ path: "/token", method: "POST", handler: oauthTokenHandler });
303 32
304export default http;33export default http;
executor/packages/convex/http/action_oauth_storage.ts+136−0
@@ -0,0 +1,136 @@
1import { internal } from "../_generated/api";
2import type { ActionCtx } from "../_generated/server";
3import type {
4 AnonOAuthClientRegistration,
5 OAuthStorage,
6 StoredSigningKey,
7} from "../../core/src/anonymous-oauth";
8
9export 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}
executor/packages/convex/http/mcp_auth.ts+239−0
@@ -0,0 +1,239 @@
1import { createRemoteJWKSet, jwtVerify } from "jose";
2import type { Id } from "../_generated/dataModel";
3import type { ActionCtx } from "../_generated/server";
4import {
5 AnonymousOAuthServer,
6} from "../../core/src/anonymous-oauth";
7import { ActionOAuthStorage } from "./action_oauth_storage";
8
9export const ANON_WORKSPACE_CLAIM = "workspace_id";
10export const ANON_SESSION_CLAIM = "session_id";
11
12export type McpAuthConfig = {
13 enabled: boolean;
14 anonymousEnabled: boolean;
15 authorizationServer: string | null;
16 jwks: ReturnType<typeof createRemoteJWKSet> | null;
17};
18
19export type VerifiedMcpToken =
20 | { provider: "workos"; subject: string }
21 | { provider: "anonymous"; subject: string; workspaceId?: Id<"workspaces">; sessionId?: string };
22
23export type ParsedMcpContext = {
24 workspaceId?: Id<"workspaces">;
25 clientId?: string;
26 sessionId?: string;
27};
28
29export function parseWorkspaceId(raw: string): Id<"workspaces"> {
30 return raw as Id<"workspaces">;
31}
32
33export 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
41export 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
65export function isAnonymousSessionId(sessionId?: string): boolean {
66 if (!sessionId) return false;
67 return sessionId.startsWith("anon_session_") || sessionId.startsWith("mcp_");
68}
69
70export 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
95export 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
108function 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
115function 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
122export 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
140export 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
195export 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
206export 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}
executor/packages/convex/http/mcp_handler.ts+132−0
@@ -0,0 +1,132 @@
1import { httpAction } from "../_generated/server";
2import { internal } from "../_generated/api";
3import { handleMcpRequest, type McpWorkspaceContext } from "../../core/src/mcp-server";
4import {
5 getMcpAuthConfig,
6 parseMcpContext,
7 unauthorizedMcpResponse,
8 verifyMcpToken,
9} from "./mcp_auth";
10import { createMcpExecutorService } from "./mcp_service";
11
12export 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});
executor/packages/convex/http/mcp_service.ts+85−0
@@ -0,0 +1,85 @@
1import type { ActionCtx } from "../_generated/server";
2import { internal } from "../_generated/api";
3import type { Id } from "../_generated/dataModel";
4import type {
5 AnonymousContext,
6 PendingApprovalRecord,
7 TaskRecord,
8 ToolDescriptor,
9} from "../../core/src/types";
10
11export 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}
executor/packages/convex/http/oauth_handlers.ts+213−0
@@ -0,0 +1,213 @@
1import { httpAction } from "../_generated/server";
2import { internal } from "../_generated/api";
3import { OAuthBadRequest } from "../../core/src/anonymous-oauth";
4import {
5 ANON_SESSION_CLAIM,
6 ANON_WORKSPACE_CLAIM,
7 getAnonymousOAuthServer,
8 getMcpAuthConfig,
9 isAnonymousSessionId,
10 parseAnonymousAuthorizeContext,
11 selectMcpAuthProvider,
12} from "./mcp_auth";
13
14export 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
69export 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
100export 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
114export 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
142export 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
189export 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});
executor/packages/convex/mcpNode.ts+2−2
@@ -3,7 +3,7 @@ · 2 unmodified lines
3import { v } from "convex/values";3import { v } from "convex/values";
4import { internal } from "./_generated/api";4import { internal } from "./_generated/api";
5import { internalAction } from "./_generated/server";5import { internalAction } from "./_generated/server";
6import { loadSourceDtsByUrlCached } from "../core/src/dts-loader";6import { loadSourceDtsByUrl } from "../core/src/dts-loader";
7import { generateToolDeclarations, typecheckCode } from "../core/src/typechecker";7import { generateToolDeclarations, typecheckCode } from "../core/src/typechecker";
8import type { ToolDescriptor } from "../core/src/types";8import 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 });
executor/packages/convex/runtimeCallbacks.ts+3−34
@@ -32,42 +32,12 @@ export const handleToolCall = action({ · 31 unmodified lines
32 },32 },
33});33});
34 34
35export 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
64export const completeRun = mutation({35export 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,
executor/packages/convex/schema.ts+29−7
@@ -44,7 +44,7 @@ const toolCallStatus = v.union( · 43 unmodified lines
44const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));44const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
45const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));45const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));
46const credentialProvider = v.union(46const credentialProvider = v.union(
47 v.literal("managed"),47 v.literal("local-convex"),
48 v.literal("workos-vault"),48 v.literal("workos-vault"),
49);49);
50const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));50const 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});
executor/packages/convex/workspace.ts+2−1
@@ -5,7 +5,7 @@ import { workspaceMutation, workspaceQuery } from "../core/src/function-builders · 4 unmodified lines
5 5
6const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));6const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
7const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));7const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
8const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault"));8const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault"));
9const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));9const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
10 10
11function redactCredential<T extends { secretJson: Record<string, unknown> }>(credential: T): T {11function 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, {
executor/packages/core/src/adapters/in-process-execution-adapter.test.ts+0−29
@@ -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
53test("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});
executor/packages/core/src/adapters/in-process-execution-adapter.ts+0−9
@@ -1,7 +1,6 @@
1import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../execution-constants";1import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../execution-constants";
2import type {2import 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
10interface InProcessExecutionAdapterOptions {9interface 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
16export class InProcessExecutionAdapter implements ExecutionAdapter {14export 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}
executor/packages/core/src/anonymous-oauth.test.ts+89−6
@@ -11,7 +11,7 @@ import { · 10 unmodified lines
11// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
12 12
13function createServer(issuer = "http://localhost:3003"): AnonymousOAuthServer {13function 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 immediately379 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 codes520 // 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 expire525 // 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-purge541 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 slots546 // 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 rejected551 // 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 immediately561 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 expiry571 // Wait for expiry
489 await new Promise((resolve) => setTimeout(resolve, 10));572 await new Promise((resolve) => setTimeout(resolve, 10));
executor/packages/core/src/anonymous-oauth.ts+116−49
@@ -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 dev23 * - `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 restarts25 * so tokens survive gateway restarts
26 *
27 * Authorization codes are always in-memory (short-lived, single-use).
28 */26 */
29 27
30import {28import {
@@ -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
62const RESERVED_JWT_CLAIMS = new Set([
63 "iss",
64 "sub",
65 "aud",
66 "exp",
67 "nbf",
68 "iat",
69 "jti",
70]);
71
72function 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
85export 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
63export interface AnonOAuthConfig {92export 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
114export class InMemoryOAuthStorage implements OAuthStorage {163export 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 private252 * 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 immediately360 * 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 identity393 // 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 exhaustion399 // 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, reject401 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 URL421 // 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 JWT487 // 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
executor/packages/core/src/convex-oauth-storage.ts+67−3
@@ -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 they4 * Persists signing keys, client registrations, and authorization codes
5 * survive gateway restarts. Keys are cached locally after first load5 * 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 validated7 * All Convex calls include an `internalSecret` argument that is validated
9 * server-side against `EXECUTOR_INTERNAL_TOKEN`, preventing unauthorized8 * 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}
executor/packages/core/src/credential-providers.test.ts+4−4
@@ -1,13 +1,13 @@
1import { expect, test } from "bun:test";1import { expect, test } from "bun:test";
2import { resolveCredentialPayload } from "./credential-providers";2import { resolveCredentialPayload } from "./credential-providers";
3 3
4test("managed provider returns stored payload", async () => {4test("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
13test("WorkOS Vault provider reads object and parses JSON", async () => {13test("WorkOS Vault provider reads object and parses JSON", async () => {
executor/packages/core/src/credential-providers.ts+2−2
@@ -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
108async function resolveManaged(108async 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
137const providers: Record<CredentialProvider, ProviderResolver> = {137const providers: Record<CredentialProvider, ProviderResolver> = {
138 managed: resolveManaged,138 "local-convex": resolveLocalConvex,
139 "workos-vault": resolveWorkosVault,139 "workos-vault": resolveWorkosVault,
140};140};
141 141
executor/packages/core/src/dts-loader.test.ts+20−19
@@ -1,7 +1,7 @@
1import { expect, test } from "bun:test";1import { expect, test } from "bun:test";
2import { clearDtsUrlCache, loadSourceDtsByUrlCached } from "./dts-loader";2import { loadSourceDtsByUrl } from "./dts-loader";
3 3
4test("loadSourceDtsByUrlCached reuses cached URL fetches", async () => {4test("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
38test("loadSourceDtsByUrlCached respects ttl expiration", async () => {36test("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});
executor/packages/core/src/dts-loader.ts+2−32
@@ -1,12 +1,3 @@
1const DEFAULT_DTS_CACHE_TTL_MS = 5 * 60_000;
2
3interface DtsCacheEntry {
4 expiresAt: number;
5 promise: Promise<string | null>;
6}
7
8const dtsUrlCache = new Map<string, DtsCacheEntry>();
9
10async function fetchDtsText(url: string): Promise<string | null> {1async 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
26function getCachedDtsPromise(url: string, ttlMs: number): Promise<string | null> {17export 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
41export 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
65export function clearDtsUrlCache(): void {
66 dtsUrlCache.clear();
67}
executor/packages/core/src/identity.ts+2−2
@@ -24,12 +24,12 @@ export type WorkspaceAccess = { · 23 unmodified lines
24 workspaceMembership: WorkspaceAccessMembership;24 workspaceMembership: WorkspaceAccessMembership;
25};25};
26 26
27export function slugify(input: string): string {27export function slugify(input: string, fallback = "team"): string {
28 const slug = input28 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
35export async function resolveAccountForRequest(35export async function resolveAccountForRequest(
executor/packages/core/src/mcp-gateway-oauth-sdk.test.ts+6−4
@@ -4,7 +4,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ · 3 unmodified lines
4import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";4import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
5import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";5import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
6import type { OAuthClientMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";6import type { OAuthClientMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
7import { AnonymousOAuthServer, OAuthBadRequest } from "./anonymous-oauth";7import { AnonymousOAuthServer, InMemoryOAuthStorage, OAuthBadRequest } from "./anonymous-oauth";
8import { handleMcpRequest, type McpWorkspaceContext } from "./mcp-server";8import { handleMcpRequest, type McpWorkspaceContext } from "./mcp-server";
9import type { AnonymousContext, CreateTaskInput, TaskRecord, ToolDescriptor } from "./types";9import type { AnonymousContext, CreateTaskInput, TaskRecord, ToolDescriptor } from "./types";
10import type { LiveTaskEvent } from "./events";10import 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
executor/packages/core/src/mcp-gateway-oauth.test.ts+9−3
@@ -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_URL21// We import the gateway init helpers directly to avoid needing CONVEX_URL
22import { AnonymousOAuthServer, OAuthBadRequest } from "./anonymous-oauth";22import { AnonymousOAuthServer, InMemoryOAuthStorage, OAuthBadRequest } from "./anonymous-oauth";
23 23
24let server: ReturnType<typeof Bun.serve>;24let server: ReturnType<typeof Bun.serve>;
25let baseUrl: string;25let baseUrl: string;
@@ -44,7 +44,10 @@ function generateCodeVerifier(): string { · 18 unmodified lines
44beforeAll(async () => {44beforeAll(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 port46 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 URL176 // Update the oauth server's issuer to match the real URL
174 // We do this by re-initializing with the correct issuer177 // 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
executor/packages/core/src/mcp-server.test.ts+9−14
@@ -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 === true47 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 subscribers51 // 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
172test("run_code MCP tool warns and previews large output", async () => {171test("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.stdout195 expect((fullResult?.data ?? "").length).toBeGreaterThan(30_000);
199 : "";
200 expect(fullStdout.length).toBeGreaterThan(30_000);
201 });196 });
202});197});
203 198
executor/packages/core/src/mcp-server.ts+20−118
@@ -1,7 +1,8 @@
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";2import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3import { Result } from "better-result";
3import { z } from "zod";4import { z } from "zod";
4import { loadSourceDtsByUrlCached } from "./dts-loader";5import { loadSourceDtsByUrl } from "./dts-loader";
5import { generateToolDeclarations, generateToolInventory, typecheckCode } from "./typechecker";6import { generateToolDeclarations, generateToolInventory, typecheckCode } from "./typechecker";
6import type { LiveTaskEvent } from "./events";7import type { LiveTaskEvent } from "./events";
7import type {8import type {
@@ -94,7 +95,7 @@ function listTopLevelToolKeys(tools: ToolDescriptor[]): string[] { · 86 unmodified lines
94}95}
95 96
96function summarizeTask(task: TaskRecord): string {97function 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 > maxStreamPreviewChars115 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 > maxStreamPreviewChars121 } 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
261function buildRunCodeDescription(tools?: ToolDescriptor[]): string {250function 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 > 0255 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
609function 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
631type McpSessionRuntime = {
632 transport: WebStandardStreamableHTTPServerTransport;
633 mcp: McpServer;
634 serviceRef: { current: McpExecutorService };
635};
636
637const mcpSessionRuntimes: Record<string, McpSessionRuntime> = Object.create(null);
638
639async function handleStatelessMcpRequest(597async 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}
executor/packages/core/src/openapi-cache.test.ts+93−0
@@ -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",
executor/packages/core/src/runtimes/cloudflare-worker-loader-runtime.ts+2−2
@@ -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 callback24 * 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 execution27 * 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.
executor/packages/core/src/runtimes/runtime-core.test.ts+28−42
@@ -4,7 +4,6 @@ import { loadExternalTools } from "../tool-sources"; · 3 unmodified lines
4import type { Id } from "../../../convex/_generated/dataModel";4import type { Id } from "../../../convex/_generated/dataModel";
5import type {5import 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
21function createRuntimeAdapter(20function 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
60test("executes tool calls and captures output", async () => {55test("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
89test("returns denied when adapter marks tool call denied", async () => {80test("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
113test("times out long-running code", async () => {102test("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
135test("sandboxed runner does not expose host globals", async () => {123test("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
173test("sandbox blocks global constructor and eval escapes", async () => {161test("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
214test("sandbox prototype pollution does not leak to host", async () => {204test("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 }
executor/packages/core/src/runtimes/runtime-core.ts+26−54
@@ -2,7 +2,7 @@ · 1 unmodified line
2 2
3// NOTE: The Vercel sandbox runtime (vercel-sandbox-runtime.ts) contains a JS3// NOTE: The Vercel sandbox runtime (vercel-sandbox-runtime.ts) contains a JS
4// string version of similar logic, built from sandbox-fragments.ts. Changes to4// string version of similar logic, built from sandbox-fragments.ts. Changes to
5// the core helpers here (formatArgs, createToolsProxy, console proxy, execution5// the core helpers here (createToolsProxy, console proxy, execution
6// loop, result mapping) should be mirrored there.6// loop, result mapping) should be mirrored there.
7import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution-constants";7import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution-constants";
8import { Result } from "better-result";8import { Result } from "better-result";
@@ -14,23 +14,25 @@ import type { · 5 unmodified lines
14} from "../types";14} from "../types";
15import { transpileForRuntime } from "./transpile";15import { transpileForRuntime } from "./transpile";
16 16
17function formatArgs(args: unknown[]): string {17async function sleep(ms: number): Promise<void> {
18 return args18 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
26function fireAndForget(promise: void | Promise<void>): void {21function 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
32async 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
36function createToolsProxy(38function 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}
executor/packages/core/src/runtimes/transpile.ts+2−7
@@ -14,14 +14,9 @@ export class TranspileError extends TaggedError("TranspileError")<{ · 13 unmodified lines
14// TypeScript module loader14// TypeScript module loader
15// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
16 16
17let cachedTypeScript: typeof import("typescript") | null | undefined;
18
19function getTypeScriptModule(): typeof import("typescript") | null {17function 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// ---------------------------------------------------------------------------
executor/packages/core/src/tool-discovery.ts+5−2
@@ -1,5 +1,6 @@
1import type { ToolDefinition } from "./types";1import type { ToolDefinition } from "./types";
2import {2import {
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) {
executor/packages/core/src/tool-sources.ts+114−41
@@ -526,6 +526,115 @@ function jsonSchemaTypeHintFallback( · 525 unmodified lines
526 return "unknown";526 return "unknown";
527}527}
528 528
529type OpenApiParameterHint = {
530 name: string;
531 required: boolean;
532 schema: Record<string, unknown>;
533};
534
535function 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
542function 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
583function 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
613function 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
529async function connectMcp(638async 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 entries1022 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 = hasInputSchema1095 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
executor/packages/core/src/typechecker.test.ts+48−1
@@ -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 = `
323export 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
executor/packages/core/src/typechecker.ts+8−8
@@ -20,14 +20,9 @@ import type { ToolDescriptor } from "./types"; · 19 unmodified lines
20// Tool declarations generation20// Tool declarations generation
21// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
22 22
23let cachedTypeScript: typeof import("typescript") | null | undefined;
24
25function getTypeScriptModule(): typeof import("typescript") | null {23function 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
33function isValidTypeExpression(typeExpression: string): boolean {28function 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();
executor/packages/core/src/types.ts+15−17
@@ -7,10 +7,19 @@ export type ApprovalStatus = "pending" | "approved" | "denied"; · 6 unmodified lines
7export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied";7export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied";
8export type PolicyDecision = "allow" | "require_approval" | "deny";8export type PolicyDecision = "allow" | "require_approval" | "deny";
9export type CredentialScope = "workspace" | "actor";9export type CredentialScope = "workspace" | "actor";
10export type CredentialProvider = "managed" | "workos-vault";10export type CredentialProvider = "local-convex" | "workos-vault";
11export type ToolApprovalMode = "auto" | "required";11export type ToolApprovalMode = "auto" | "required";
12export type ToolSourceType = "mcp" | "openapi" | "graphql";12export type ToolSourceType = "mcp" | "openapi" | "graphql";
13 13
14export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
15
16export interface SourceAuthProfile {
17 type: SourceAuthType;
18 mode?: CredentialScope;
19 header?: string;
20 inferred: boolean;
21}
22
14export interface TaskRecord {23export 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
86export interface CredentialRecord {92export 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
168export interface SandboxExecutionResult {177export 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
196export type RuntimeOutputStream = "stdout" | "stderr";
197
198export interface RuntimeOutputEvent {
199 runId: string;
200 stream: RuntimeOutputStream;
201 line: string;
202 timestamp: number;
203}
204
205export interface ExecutionAdapter {204export 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
210export interface SandboxRuntime {208export interface SandboxRuntime {
executor/packages/runner-sandbox-host/src/index.ts+4−26
@@ -17,9 +17,8 @@ · 16 unmodified lines
17 * (passed as a loopback service binding via `ctx.exports`) which invokes17 * (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 the21 * are included in terminal run results.
22 * ToolBridge binding.
23 *22 *
24 * 5. `/v1/runs` returns an accepted dispatch response immediately. Terminal23 * 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
90interface RunResult {89interface 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
144const failedResult = (error: string): RunResult => ({142const 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,
executor/packages/runner-sandbox-host/src/isolate/harness.isolate.js+17−42
@@ -3,19 +3,6 @@ import { run } from "./user-code.js"; · 2 unmodified lines
3 3
4const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:";4const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:";
5 5
6function 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
19async function sleep(ms) {6async 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
51export default {38function 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
49export 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 }