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
2626 # EXECUTOR_BACKEND_SITE_PORT=5411
2727 # EXECUTOR_WEB_PORT=5312
2828
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+
2933 # ── Discord bot (optional — omit to skip bot startup) ──
3034 # DISCORD_BOT_TOKEN=...
3135
executor/.gitignore+2−0
@@ -10,3 +10,5 @@ dist · 9 unmodified lines
1010 .vercel
1111 .env*.local
1212 .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
121121 - `/mcp` (direct Convex MCP transport)
122122 - `/.well-known/oauth-protected-resource`
123123 - `/.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)
124128 - `/internal/runs/:runId/tool-call`
125−- `/internal/runs/:runId/output`
126129
127−MCP bearer-token verification is enabled when `MCP_AUTHORIZATION_SERVER` (or `MCP_AUTHORIZATION_SERVER_URL`) is configured.
130+MCP bearer-token verification is enabled when `MCP_AUTHORIZATION_SERVER` / `MCP_AUTHORIZATION_SERVER_URL` is configured, or when `MCP_ENABLE_ANONYMOUS_OAUTH=1`.
128131
129132 ## Configuration Reference
130133
@@ -144,6 +147,7 @@ Important env vars (see root `.env.example` for the base template): · 13 unmodified lines
144147 - `STRIPE_PRICE_ID`
145148 - MCP auth integration:
146149 - `MCP_AUTHORIZATION_SERVER` or `MCP_AUTHORIZATION_SERVER_URL`
150+ - `MCP_ENABLE_ANONYMOUS_OAUTH` (`1` to enable anonymous OAuth without external auth server)
147151 - Managed runtime:
148152 - `EXECUTOR_RUNTIME_DIR`
149153 - `EXECUTOR_BACKEND_PORT`
executor/packages/convex/_generated/api.d.ts+83−34
@@ -29,6 +29,18 @@ export declare const api: { · 28 unmodified lines
2929 >;
3030 };
3131 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+ >;
3244 getActiveSigningKey: FunctionReference<
3345 "query",
3446 "public",
@@ -41,6 +53,12 @@ export declare const api: { · 6 unmodified lines
4153 { clientId: string; internalSecret: string },
4254 any
4355 >;
56+ purgeExpiredAuthorizationCodes: FunctionReference<
57+ "mutation",
58+ "public",
59+ { internalSecret: string; now: number },
60+ any
61+ >;
4462 registerClient: FunctionReference<
4563 "mutation",
4664 "public",
@@ -52,6 +70,23 @@ export declare const api: { · 5 unmodified lines
5270 },
5371 any
5472 >;
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+ >;
5590 storeSigningKey: FunctionReference<
5691 "mutation",
5792 "public",
@@ -125,7 +160,7 @@ export declare const api: { · 67 unmodified lines
125160 {
126161 actorId?: string;
127162 id?: string;
128− provider?: "managed" | "workos-vault";
163+ provider?: "local-convex" | "workos-vault";
129164 scope: "workspace" | "actor";
130165 secretJson: any;
131166 sessionId?: string;
@@ -166,6 +201,12 @@ export declare const api: { · 34 unmodified lines
166201 >;
167202 };
168203 executorNode: {
204+ listToolDtsUrls: FunctionReference<
205+ "action",
206+ "public",
207+ { actorId?: string; sessionId?: string; workspaceId: Id<"workspaces"> },
208+ any
209+ >;
169210 listToolsWithWarnings: FunctionReference<
170211 "action",
171212 "public",
@@ -277,18 +318,6 @@ export declare const api: { · 105 unmodified lines
277318 >;
278319 };
279320 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− >;
292321 completeRun: FunctionReference<
293322 "mutation",
294323 "public",
@@ -297,10 +326,9 @@ export declare const api: { · 2 unmodified lines
297326 error?: string;
298327 exitCode?: number;
299328 internalSecret: string;
329+ result?: any;
300330 runId: string;
301331 status: "completed" | "failed" | "timed_out" | "denied";
302− stderr?: string;
303− stdout?: string;
304332 },
305333 any
306334 >;
@@ -407,7 +435,8 @@ export declare const api: { · 100 unmodified lines
407435 {
408436 actorId?: string;
409437 id?: string;
410− provider?: "managed" | "workos-vault";
438+ overridesJson?: any;
439+ provider?: "local-convex" | "workos-vault";
411440 scope: "workspace" | "actor";
412441 secretJson: any;
413442 sessionId?: string;
@@ -528,6 +557,18 @@ export declare const internal: { · 114 unmodified lines
528557 { sessionId?: string },
529558 any
530559 >;
560+ consumeAnonymousOauthAuthorizationCode: FunctionReference<
561+ "mutation",
562+ "internal",
563+ { code: string },
564+ any
565+ >;
566+ countAnonymousOauthAuthorizationCodes: FunctionReference<
567+ "query",
568+ "internal",
569+ {},
570+ any
571+ >;
531572 createApproval: FunctionReference<
532573 "mutation",
533574 "internal",
@@ -567,7 +608,6 @@ export declare const internal: { · 33 unmodified lines
567608 {
568609 callId: string;
569610 error?: string;
570− output?: any;
571611 status: "completed" | "failed" | "denied";
572612 taskId: string;
573613 },
@@ -675,9 +715,8 @@ export declare const internal: { · 101 unmodified lines
675715 {
676716 error?: string;
677717 exitCode?: number;
718+ result?: any;
678719 status: "completed" | "failed" | "timed_out" | "denied";
679− stderr: string;
680− stdout: string;
681720 taskId: string;
682721 },
683722 any
@@ -688,6 +727,12 @@ export declare const internal: { · 4 unmodified lines
688727 { taskId: string },
689728 any
690729 >;
730+ purgeExpiredAnonymousOauthAuthorizationCodes: FunctionReference<
731+ "mutation",
732+ "internal",
733+ { now: number },
734+ any
735+ >;
691736 registerAnonymousOauthClient: FunctionReference<
692737 "mutation",
693738 "internal",
@@ -722,6 +767,22 @@ export declare const internal: { · 28 unmodified lines
722767 { approvalId: string; callId: string; taskId: string },
723768 any
724769 >;
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+ >;
725786 storeAnonymousOauthSigningKey: FunctionReference<
726787 "mutation",
727788 "internal",
@@ -753,7 +814,8 @@ export declare const internal: { · 25 unmodified lines
753814 {
754815 actorId?: string;
755816 id?: string;
756− provider?: "managed" | "workos-vault";
817+ overridesJson?: any;
818+ provider?: "local-convex" | "workos-vault";
757819 scope: "workspace" | "actor";
758820 secretJson: any;
759821 sourceKey: string;
@@ -766,7 +828,6 @@ export declare const internal: { · 6 unmodified lines
766828 "internal",
767829 {
768830 callId: string;
769− input?: any;
770831 taskId: string;
771832 toolPath: string;
772833 workspaceId: Id<"workspaces">;
@@ -788,17 +849,6 @@ export declare const internal: { · 15 unmodified lines
788849 >;
789850 };
790851 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− >;
802852 completeRuntimeRun: FunctionReference<
803853 "mutation",
804854 "internal",
@@ -806,10 +856,9 @@ export declare const internal: { · 1 unmodified line
806856 durationMs?: number;
807857 error?: string;
808858 exitCode?: number;
859+ result?: any;
809860 runId: string;
810861 status: "completed" | "failed" | "timed_out" | "denied";
811− stderr?: string;
812− stdout?: string;
813862 },
814863 any
815864 >;
executor/packages/convex/anonymousOauth.ts+58−0
@@ -83,3 +83,61 @@ export const getClient = query({ · 82 unmodified lines
8383 );
8484 },
8585 });
86+
87+// ── Authorization Codes ─────────────────────────────────────────────────────
88+
89+export const storeAuthorizationCode = mutation({
90+ args: {
91+ internalSecret: v.string(),
92+ code: v.string(),
93+ clientId: v.string(),
94+ redirectUri: v.string(),
95+ codeChallenge: v.string(),
96+ codeChallengeMethod: v.string(),
97+ actorId: v.string(),
98+ tokenClaims: v.optional(v.any()),
99+ expiresAt: v.number(),
100+ createdAt: v.number(),
101+ },
102+ handler: async (ctx, args) => {
103+ requireInternalSecret(args.internalSecret);
104+ const { internalSecret: _, ...payload } = args;
105+ await ctx.runMutation(internal.database.storeAnonymousOauthAuthorizationCode, payload);
106+ },
107+});
108+
109+export const consumeAuthorizationCode = mutation({
110+ args: {
111+ internalSecret: v.string(),
112+ code: v.string(),
113+ },
114+ handler: async (ctx, args) => {
115+ requireInternalSecret(args.internalSecret);
116+ return await ctx.runMutation(internal.database.consumeAnonymousOauthAuthorizationCode, {
117+ code: args.code,
118+ });
119+ },
120+});
121+
122+export const purgeExpiredAuthorizationCodes = mutation({
123+ args: {
124+ internalSecret: v.string(),
125+ now: v.number(),
126+ },
127+ handler: async (ctx, args) => {
128+ requireInternalSecret(args.internalSecret);
129+ return await ctx.runMutation(internal.database.purgeExpiredAnonymousOauthAuthorizationCodes, {
130+ now: args.now,
131+ });
132+ },
133+});
134+
135+export const countAuthorizationCodes = query({
136+ args: {
137+ internalSecret: v.string(),
138+ },
139+ handler: async (ctx, args) => {
140+ requireInternalSecret(args.internalSecret);
141+ return await ctx.runQuery(internal.database.countAnonymousOauthAuthorizationCodes, {});
142+ },
143+});
executor/packages/convex/auth.ts+3−10
@@ -3,6 +3,7 @@ import { components, internal } from "./_generated/api"; · 2 unmodified lines
33 import type { DataModel, Doc, Id } from "./_generated/dataModel";
44 import type { MutationCtx } from "./_generated/server";
55 import { internalMutation, mutation } from "./_generated/server";
6+import { slugify } from "../core/src/identity";
67 import { ensureUniqueSlug } from "../core/src/slug";
78
89 type DbCtx = Pick<MutationCtx, "db">;
@@ -48,14 +49,6 @@ export const authKit = · 39 unmodified lines
4849 registerRoutes: () => {},
4950 } as Pick<AuthKit<DataModel>, "registerRoutes">);
5051
51−function slugify(input: string): string {
52− const slug = input
53− .toLowerCase()
54− .replace(/[^a-z0-9]+/g, "-")
55− .replace(/^-+|-+$/g, "");
56− return slug.length > 0 ? slug : "workspace";
57−}
58−
5952 function titleCaseWords(input: string): string {
6053 return input
6154 .split(" ")
@@ -155,7 +148,7 @@ async function getFirstWorkspaceByOrganizationId(ctx: DbCtx, organizationId: Id< · 93 unmodified lines
155148 }
156149
157150 async function ensureUniqueOrganizationSlug(ctx: DbCtx, baseName: string): Promise<string> {
158− const baseSlug = slugify(baseName);
151+ const baseSlug = slugify(baseName, "workspace");
159152 return await ensureUniqueSlug(baseSlug, async (candidate) => {
160153 const collision = await ctx.db
161154 .query("organizations")
@@ -333,7 +326,7 @@ async function ensurePersonalWorkspace( · 171 unmodified lines
333326 updatedAt: opts.now,
334327 });
335328
336− const baseSlug = slugify(opts.email.split("@")[0] ?? opts.workosUserId);
329+ const baseSlug = slugify(opts.email.split("@")[0] ?? opts.workosUserId, "workspace");
337330 const workspaceId = await ctx.db.insert("workspaces", {
338331 organizationId,
339332 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
66 import { action } from "./_generated/server";
77
88 const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
9−const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault"));
9+const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault"));
10+
11+type SecretBackend = "local-convex" | "workos-vault";
1012
1113 function asRecord(value: unknown): Record<string, unknown> {
1214 if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -21,6 +23,17 @@ function normalizedActorId(scope: "workspace" | "actor", actorId?: string): stri · 8 unmodified lines
2123 return actorId.trim();
2224 }
2325
26+function configuredSecretBackend(): SecretBackend {
27+ const explicit = process.env.EXECUTOR_SECRET_BACKEND?.trim().toLowerCase();
28+ if (explicit === "workos" || explicit === "workos-vault") {
29+ return "workos-vault";
30+ }
31+ if (explicit === "local" || explicit === "local-convex") {
32+ return "local-convex";
33+ }
34+ return process.env.WORKOS_API_KEY?.trim() ? "workos-vault" : "local-convex";
35+}
36+
2437 function extractObjectId(secretJson: Record<string, unknown>): string | null {
2538 const candidate =
2639 (typeof secretJson.objectId === "string" ? secretJson.objectId : "") ||
@@ -29,13 +42,24 @@ function extractObjectId(secretJson: Record<string, unknown>): string | null { · 2 unmodified lines
2942 return trimmed.length > 0 ? trimmed : null;
3043 }
3144
32−function stripObjectRef(secretJson: Record<string, unknown>): Record<string, unknown> {
33− const { objectId: _objectId, id: _id, ...rest } = secretJson;
34− return asRecord(rest);
35−}
45+function extractHeaderOverrides(secretJson: Record<string, unknown>): {
46+ cleanSecret: Record<string, unknown>;
47+ overridesJson: Record<string, unknown>;
48+} {
49+ const rawHeaders = asRecord(secretJson.__headers);
50+ const headers: Record<string, string> = {};
51+ for (const [name, value] of Object.entries(rawHeaders)) {
52+ if (!name) continue;
53+ const text = typeof value === "string" ? value.trim() : String(value ?? "").trim();
54+ if (!text) continue;
55+ headers[name.trim()] = text;
56+ }
3657
37−function hasPayload(secretJson: Record<string, unknown>): boolean {
38− return Object.keys(stripObjectRef(secretJson)).length > 0;
58+ const { __headers: _headers, ...rest } = secretJson;
59+ return {
60+ cleanSecret: asRecord(rest),
61+ overridesJson: Object.keys(headers).length > 0 ? { headers } : {},
62+ };
3963 }
4064
4165 function buildVaultObjectName(args: {
@@ -50,7 +74,7 @@ function buildVaultObjectName(args: { · 8 unmodified lines
5074 .replace(/^-+|-+$/g, "")
5175 .slice(0, 64);
5276
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)}`;
5478 }
5579
5680 function workosClient(): WorkOS {
@@ -162,19 +186,58 @@ export const upsertCredential = action({ · 105 unmodified lines
162186 throw new Error("actorId must match the authenticated workspace actor");
163187 }
164188
165− const provider = args.provider ?? "managed";
166189 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+ }
168231
169− if (provider === "managed") {
170232 return await ctx.runMutation(internal.database.upsertCredential, {
171− id: args.id,
233+ id: connectionId,
172234 workspaceId: args.workspaceId,
173235 sourceKey: args.sourceKey,
174236 scope: args.scope,
175− actorId,
176− provider,
177− secretJson: submittedSecret,
237+ ...(args.scope === "actor" ? { actorId } : {}),
238+ provider: "local-convex",
239+ secretJson: finalSecret,
240+ overridesJson,
178241 });
179242 }
180243
@@ -183,28 +246,19 @@ export const upsertCredential = action({ · 2 unmodified lines
183246 throw new Error("Encrypted storage value looks like a GitHub token. Paste the token in the token field.");
184247 }
185248
186− const existing: Record<string, unknown> | null = await ctx.runQuery(internal.database.resolveCredential, {
187− workspaceId: args.workspaceId,
188− sourceKey: args.sourceKey,
189− scope: args.scope,
190− ...(args.scope === "actor" ? { actorId } : {}),
191− });
192− const existingObjectId: string | null = existing ? extractObjectId(asRecord(existing.secretJson)) : null;
193−
194− const objectId = (() => {
195− if (submittedObjectId) return submittedObjectId;
196− return null;
197− })();
249+ const existingObjectId = extractObjectId(
250+ asRecord(existingConnection?.secretJson ?? existingBinding?.secretJson),
251+ );
198252
199− let finalObjectId = objectId;
200− if (!finalObjectId && hasPayload(submittedSecret)) {
253+ let finalObjectId = submittedObjectId;
254+ if (!finalObjectId && Object.keys(submittedSecret).length > 0) {
201255 finalObjectId = await upsertVaultObject({
202256 workspaceId: args.workspaceId,
203257 sourceKey: args.sourceKey,
204258 scope: args.scope,
205259 actorId,
206260 existingObjectId,
207− payload: stripObjectRef(submittedSecret),
261+ payload: submittedSecret,
208262 });
209263 }
210264
@@ -213,17 +267,18 @@ export const upsertCredential = action({ · 2 unmodified lines
213267 }
214268
215269 if (!finalObjectId) {
216− throw new Error("Encrypted storage requires credential values");
270+ throw new Error("Credential values are required");
217271 }
218272
219273 return await ctx.runMutation(internal.database.upsertCredential, {
220− id: args.id,
274+ id: connectionId,
221275 workspaceId: args.workspaceId,
222276 sourceKey: args.sourceKey,
223277 scope: args.scope,
224278 ...(args.scope === "actor" ? { actorId } : {}),
225− provider,
279+ provider: "workos-vault",
226280 secretJson: { objectId: finalObjectId },
281+ overridesJson,
227282 });
228283 },
229284 });
executor/packages/convex/database.test.ts+52−8
@@ -56,11 +56,10 @@ test("task lifecycle supports queue, run, and complete", async () => { · 55 unmodified lines
5656 const secondRun = await t.mutation(internal.database.markTaskRunning, { taskId: "task_1" });
5757 expect(secondRun).toBeNull();
5858
59− const finished = await t.mutation(internal.database.markTaskFinished, {
59+ const finished = await t.mutation(internal.database.markTaskFinished as any, {
6060 taskId: "task_1",
6161 status: "completed",
62− stdout: "ok",
63− stderr: "",
62+ result: { ok: true },
6463 exitCode: 0,
6564 });
6665 expect(finished?.status).toBe("completed");
@@ -169,11 +168,11 @@ test("credentials persist provider and resolve by scope", async () => { · 102 unmodified lines
169168 workspaceId: wsId,
170169 sourceKey: "openapi:github",
171170 scope: "workspace",
172− provider: "managed",
171+ provider: "local-convex",
173172 secretJson: { token: "workspace-token" },
174173 });
175174
176− expect(workspaceCredential.provider).toBe("managed");
175+ expect(workspaceCredential.provider).toBe("local-convex");
177176
178177 const actorCredential = await t.mutation(internal.database.upsertCredential, {
179178 workspaceId: wsId,
@@ -192,7 +191,7 @@ test("credentials persist provider and resolve by scope", async () => { · 12 unmodified lines
192191 sourceKey: "openapi:github",
193192 scope: "workspace",
194193 });
195− expect(resolvedWorkspace?.provider).toBe("managed");
194+ expect(resolvedWorkspace?.provider).toBe("local-convex");
196195
197196 const resolvedActor = await t.query(internal.database.resolveCredential, {
198197 workspaceId: wsId,
@@ -203,7 +202,7 @@ test("credentials persist provider and resolve by scope", async () => { · 4 unmodified lines
203202 expect(resolvedActor?.provider).toBe("workos-vault");
204203 });
205204
206−test("upsertCredential defaults provider to managed", async () => {
205+test("upsertCredential defaults provider to local provider", async () => {
207206 const t = setup();
208207 const wsId = await seedWorkspace(t, "ws_default_provider");
209208
@@ -214,5 +213,50 @@ test("upsertCredential defaults provider to managed", async () => { · 4 unmodified lines
214213 secretJson: { token: "sk_test_123" },
215214 });
216215
217− expect(credential.provider).toBe("managed");
216+ expect(credential.provider).toBe("local-convex");
217+});
218+
219+test("credentials can link one connection to multiple sources", async () => {
220+ const t = setup();
221+ const wsId = await seedWorkspace(t, "ws_linked_connection");
222+
223+ const primary = await t.mutation(internal.database.upsertCredential, {
224+ workspaceId: wsId,
225+ sourceKey: "source:github",
226+ scope: "workspace",
227+ secretJson: { token: "token_v1" },
228+ });
229+
230+ await t.mutation(internal.database.upsertCredential, {
231+ id: primary.id,
232+ workspaceId: wsId,
233+ sourceKey: "source:stripe",
234+ scope: "workspace",
235+ secretJson: {},
236+ overridesJson: { headers: { "x-tenant-id": "acme" } },
237+ });
238+
239+ const linked = await t.query(internal.database.resolveCredential, {
240+ workspaceId: wsId,
241+ sourceKey: "source:stripe",
242+ scope: "workspace",
243+ });
244+ expect(linked?.id).toBe(primary.id);
245+ expect(linked?.secretJson).toEqual({ token: "token_v1" });
246+ expect(linked?.overridesJson).toEqual({ headers: { "x-tenant-id": "acme" } });
247+
248+ await t.mutation(internal.database.upsertCredential, {
249+ id: primary.id,
250+ workspaceId: wsId,
251+ sourceKey: "source:github",
252+ scope: "workspace",
253+ secretJson: { token: "token_v2" },
254+ });
255+
256+ const relinked = await t.query(internal.database.resolveCredential, {
257+ workspaceId: wsId,
258+ sourceKey: "source:stripe",
259+ scope: "workspace",
260+ });
261+ expect(relinked?.secretJson).toEqual({ token: "token_v2" });
218262 });
executor/packages/convex/database.ts+246−37
@@ -2,6 +2,7 @@ import { v } from "convex/values"; · 1 unmodified line
22 import type { Doc } from "./_generated/dataModel";
33 import { type MutationCtx, type QueryCtx } from "./_generated/server";
44 import { internalMutation, internalQuery } from "./_generated/server";
5+import { slugify } from "../core/src/identity";
56 import { ensureUniqueSlug } from "../core/src/slug";
67 import type { TaskStatus } from "../core/src/types";
78
@@ -24,21 +25,13 @@ const terminalToolCallStatusValidator = v.union( · 16 unmodified lines
2425 const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
2526 const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
2627 const credentialProviderValidator = v.union(
27− v.literal("managed"),
28+ v.literal("local-convex"),
2829 v.literal("workos-vault"),
2930 );
3031 const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
3132
32−function slugify(input: string): string {
33− const slug = input
34− .toLowerCase()
35− .replace(/[^a-z0-9]+/g, "-")
36− .replace(/^-+|-+$/g, "");
37− return slug.length > 0 ? slug : "workspace";
38−}
39−
4033 async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> {
41− const baseSlug = slugify(baseName);
34+ const baseSlug = slugify(baseName, "workspace");
4235 return await ensureUniqueSlug(baseSlug, async (candidate) => {
4336 const collision = await ctx.db
4437 .query("organizations")
@@ -96,6 +89,46 @@ function asRecord(value: unknown): Record<string, unknown> { · 51 unmodified lines
9689 return {};
9790 }
9891
92+function stableStringify(value: unknown): string {
93+ if (Array.isArray(value)) {
94+ return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
95+ }
96+ if (value && typeof value === "object") {
97+ const record = value as Record<string, unknown>;
98+ const keys = Object.keys(record).sort();
99+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
100+ }
101+ return JSON.stringify(value);
102+}
103+
104+function normalizeSourceAuthFingerprint(value: unknown): string {
105+ const auth = asRecord(value);
106+ const type = typeof auth.type === "string" ? auth.type.trim() : "none";
107+ const mode = auth.mode === "actor" ? "actor" : "workspace";
108+ const header = typeof auth.header === "string" ? auth.header.trim().toLowerCase() : "";
109+ return stableStringify({
110+ type: type || "none",
111+ mode,
112+ ...(header ? { header } : {}),
113+ });
114+}
115+
116+function computeSourceSpecHash(type: "mcp" | "openapi" | "graphql", config: Record<string, unknown>): string {
117+ if (type === "openapi") {
118+ const spec = config.spec;
119+ if (typeof spec === "string") {
120+ return `openapi:${spec.trim()}`;
121+ }
122+ return `openapi:${stableStringify(spec)}`;
123+ }
124+ if (type === "graphql") {
125+ const endpoint = typeof config.endpoint === "string" ? config.endpoint.trim() : "";
126+ return `graphql:${endpoint}`;
127+ }
128+ const url = typeof config.url === "string" ? config.url.trim() : "";
129+ return `mcp:${url}`;
130+}
131+
99132 function mapTask(doc: Doc<"tasks">) {
100133 return {
101134 id: doc.taskId,
@@ -112,8 +145,7 @@ function mapTask(doc: Doc<"tasks">) { · 10 unmodified lines
112145 startedAt: doc.startedAt,
113146 completedAt: doc.completedAt,
114147 error: doc.error,
115− stdout: doc.stdout,
116− stderr: doc.stderr,
148+ result: doc.result,
117149 exitCode: doc.exitCode,
118150 };
119151 }
@@ -138,10 +170,8 @@ function mapToolCall(doc: Doc<"toolCalls">) { · 18 unmodified lines
138170 callId: doc.callId,
139171 workspaceId: doc.workspaceId,
140172 toolPath: doc.toolPath,
141− input: doc.input,
142173 status: doc.status,
143174 approvalId: doc.approvalId,
144− output: doc.output,
145175 error: doc.error,
146176 createdAt: doc.createdAt,
147177 updatedAt: doc.updatedAt,
@@ -166,12 +196,15 @@ function mapPolicy(doc: Doc<"accessPolicies">) { · 18 unmodified lines
166196 function mapCredential(doc: Doc<"sourceCredentials">) {
167197 return {
168198 id: doc.credentialId,
199+ bindingId: doc.bindingId,
169200 workspaceId: doc.workspaceId,
170201 sourceKey: doc.sourceKey,
171202 scope: doc.scope,
172203 actorId: doc.actorId || undefined,
173− provider: doc.provider ?? "managed",
204+ provider: doc.provider,
174205 secretJson: asRecord(doc.secretJson),
206+ overridesJson: asRecord(doc.overridesJson),
207+ boundAuthFingerprint: doc.boundAuthFingerprint,
175208 createdAt: doc.createdAt,
176209 updatedAt: doc.updatedAt,
177210 };
@@ -184,6 +217,8 @@ function mapSource(doc: Doc<"toolSources">) { · 6 unmodified lines
184217 name: doc.name,
185218 type: doc.type,
186219 config: asRecord(doc.config),
220+ specHash: doc.specHash,
221+ authFingerprint: doc.authFingerprint,
187222 enabled: Boolean(doc.enabled),
188223 createdAt: doc.createdAt,
189224 updatedAt: doc.updatedAt,
@@ -468,8 +503,7 @@ export const markTaskFinished = internalMutation({ · 278 unmodified lines
468503 args: {
469504 taskId: v.string(),
470505 status: completedTaskStatusValidator,
471− stdout: v.string(),
472− stderr: v.string(),
506+ result: v.optional(v.any()),
473507 exitCode: v.optional(v.number()),
474508 error: v.optional(v.string()),
475509 },
@@ -486,8 +520,7 @@ export const markTaskFinished = internalMutation({ · 10 unmodified lines
486520 const now = Date.now();
487521 await ctx.db.patch(doc._id, {
488522 status: args.status,
489− stdout: args.stdout,
490− stderr: args.stderr,
523+ result: args.result,
491524 exitCode: args.exitCode,
492525 error: args.error,
493526 completedAt: now,
@@ -655,7 +688,6 @@ export const upsertToolCallRequested = internalMutation({ · 161 unmodified lines
655688 callId: v.string(),
656689 workspaceId: v.id("workspaces"),
657690 toolPath: v.string(),
658− input: v.optional(v.any()),
659691 },
660692 handler: async (ctx, args) => {
661693 const existing = await getToolCallDoc(ctx, args.taskId, args.callId);
@@ -669,7 +701,6 @@ export const upsertToolCallRequested = internalMutation({ · 7 unmodified lines
669701 callId: args.callId,
670702 workspaceId: args.workspaceId,
671703 toolPath: args.toolPath,
672− input: args.input ?? {},
673704 status: "requested",
674705 createdAt: now,
675706 updatedAt: now,
@@ -730,7 +761,6 @@ export const finishToolCall = internalMutation({ · 54 unmodified lines
730761 taskId: v.string(),
731762 callId: v.string(),
732763 status: terminalToolCallStatusValidator,
733− output: v.optional(v.any()),
734764 error: v.optional(v.string()),
735765 },
736766 handler: async (ctx, args) => {
@@ -746,7 +776,6 @@ export const finishToolCall = internalMutation({ · 9 unmodified lines
746776 const now = Date.now();
747777 await ctx.db.patch(doc._id, {
748778 status: args.status,
749− output: args.output,
750779 error: args.error,
751780 updatedAt: now,
752781 completedAt: now,
@@ -924,6 +953,33 @@ export const listAccessPolicies = internalQuery({ · 171 unmodified lines
924953 },
925954 });
926955
956+async function computeBoundAuthFingerprint(
957+ ctx: Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">,
958+ workspaceId: Doc<"workspaces">["_id"],
959+ sourceKey: string,
960+): Promise<string> {
961+ const prefix = "source:";
962+ if (!sourceKey.startsWith(prefix)) {
963+ return normalizeSourceAuthFingerprint({ type: "none" });
964+ }
965+
966+ const sourceId = sourceKey.slice(prefix.length).trim();
967+ if (!sourceId) {
968+ return normalizeSourceAuthFingerprint({ type: "none" });
969+ }
970+
971+ const source = await ctx.db
972+ .query("toolSources")
973+ .withIndex("by_source_id", (q) => q.eq("sourceId", sourceId))
974+ .unique();
975+
976+ if (!source || source.workspaceId !== workspaceId) {
977+ return normalizeSourceAuthFingerprint({ type: "none" });
978+ }
979+
980+ return normalizeSourceAuthFingerprint(asRecord(source.config).auth);
981+}
982+
927983 export const upsertCredential = internalMutation({
928984 args: {
929985 id: v.optional(v.string()),
@@ -933,10 +989,13 @@ export const upsertCredential = internalMutation({ · 3 unmodified lines
933989 actorId: v.optional(v.string()),
934990 provider: v.optional(credentialProviderValidator),
935991 secretJson: v.any(),
992+ overridesJson: v.optional(v.any()),
936993 },
937994 handler: async (ctx, args) => {
938995 const now = Date.now();
939996 const actorId = args.scope === "actor" ? (args.actorId?.trim() || "") : "";
997+ const submittedSecret = asRecord(args.secretJson);
998+ const hasSubmittedSecret = Object.keys(submittedSecret).length > 0;
940999
9411000 const existing = await ctx.db
9421001 .query("sourceCredentials")
@@ -949,23 +1008,71 @@ export const upsertCredential = internalMutation({ · 6 unmodified lines
9491008 )
9501009 .unique();
9511010
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+ }
9531054
9541055 if (existing) {
9551056 await ctx.db.patch(existing._id, {
1057+ credentialId: connectionId,
9561058 provider,
957− secretJson: asRecord(args.secretJson),
1059+ secretJson: finalSecret,
1060+ overridesJson,
1061+ boundAuthFingerprint,
9581062 updatedAt: now,
9591063 });
9601064 } else {
9611065 await ctx.db.insert("sourceCredentials", {
962− credentialId: args.id ?? `cred_${crypto.randomUUID()}`,
1066+ bindingId: `bind_${crypto.randomUUID()}`,
1067+ credentialId: connectionId,
9631068 workspaceId: args.workspaceId,
9641069 sourceKey: args.sourceKey,
9651070 scope: args.scope,
9661071 actorId,
9671072 provider,
968− secretJson: asRecord(args.secretJson),
1073+ secretJson: finalSecret,
1074+ overridesJson,
1075+ boundAuthFingerprint,
9691076 createdAt: now,
9701077 updatedAt: now,
9711078 });
@@ -1005,16 +1112,14 @@ export const listCredentials = internalQuery({ · 33 unmodified lines
10051112 export const listCredentialProviders = internalQuery({
10061113 args: {},
10071114 handler: async () => {
1115+ const workosEnabled = Boolean(process.env.WORKOS_API_KEY?.trim());
10081116 return [
10091117 {
1010− id: "managed",
1011− label: "Managed",
1012− description: "Store credential payload in Executor's sourceCredentials table.",
1013− },
1014− {
1015− id: "workos-vault",
1016− label: "Encrypted",
1017− description: "Store credential payload in encrypted external storage.",
1118+ id: workosEnabled ? "workos-vault" : "local-convex",
1119+ label: workosEnabled ? "Encrypted" : "Local",
1120+ description: workosEnabled
1121+ ? "Secrets are stored in WorkOS Vault."
1122+ : "Secrets are stored locally in Convex on this machine.",
10181123 },
10191124 ] as const;
10201125 },
@@ -1075,6 +1180,9 @@ export const upsertToolSource = internalMutation({ · 54 unmodified lines
10751180 handler: async (ctx, args) => {
10761181 const now = Date.now();
10771182 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);
10781186 const [existing, conflict] = await Promise.all([
10791187 ctx.db
10801188 .query("toolSources")
@@ -1095,7 +1203,9 @@ export const upsertToolSource = internalMutation({ · 14 unmodified lines
10951203 workspaceId: args.workspaceId,
10961204 name: args.name,
10971205 type: args.type,
1098− config: asRecord(args.config),
1206+ config,
1207+ specHash,
1208+ authFingerprint,
10991209 enabled: args.enabled !== false,
11001210 updatedAt: now,
11011211 });
@@ -1105,7 +1215,9 @@ export const upsertToolSource = internalMutation({ · 3 unmodified lines
11051215 workspaceId: args.workspaceId,
11061216 name: args.name,
11071217 type: args.type,
1108− config: asRecord(args.config),
1218+ config,
1219+ specHash,
1220+ authFingerprint,
11091221 enabled: args.enabled !== false,
11101222 createdAt: now,
11111223 updatedAt: now,
@@ -1147,6 +1259,18 @@ export const deleteToolSource = internalMutation({ · 35 unmodified lines
11471259 return false;
11481260 }
11491261
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+
11501274 await ctx.db.delete(doc._id);
11511275 return true;
11521276 },
@@ -1320,3 +1444,88 @@ export const getAnonymousOauthClient = internalQuery({ · 167 unmodified lines
13201444 };
13211445 },
13221446 });
1447+
1448+// ── Anonymous OAuth Authorization Codes ──────────────────────────────────────
1449+
1450+export const storeAnonymousOauthAuthorizationCode = internalMutation({
1451+ args: {
1452+ code: v.string(),
1453+ clientId: v.string(),
1454+ redirectUri: v.string(),
1455+ codeChallenge: v.string(),
1456+ codeChallengeMethod: v.string(),
1457+ actorId: v.string(),
1458+ tokenClaims: v.optional(v.any()),
1459+ expiresAt: v.number(),
1460+ createdAt: v.number(),
1461+ },
1462+ handler: async (ctx, args) => {
1463+ await ctx.db.insert("anonymousOauthCodes", {
1464+ code: args.code,
1465+ clientId: args.clientId,
1466+ redirectUri: args.redirectUri,
1467+ codeChallenge: args.codeChallenge,
1468+ codeChallengeMethod: args.codeChallengeMethod,
1469+ actorId: args.actorId,
1470+ tokenClaims: args.tokenClaims,
1471+ expiresAt: args.expiresAt,
1472+ createdAt: args.createdAt,
1473+ });
1474+ },
1475+});
1476+
1477+export const consumeAnonymousOauthAuthorizationCode = internalMutation({
1478+ args: {
1479+ code: v.string(),
1480+ },
1481+ handler: async (ctx, args) => {
1482+ const doc = await ctx.db
1483+ .query("anonymousOauthCodes")
1484+ .withIndex("by_code", (q) => q.eq("code", args.code))
1485+ .unique();
1486+
1487+ if (!doc) {
1488+ return null;
1489+ }
1490+
1491+ await ctx.db.delete(doc._id);
1492+
1493+ return {
1494+ code: doc.code,
1495+ clientId: doc.clientId,
1496+ redirectUri: doc.redirectUri,
1497+ codeChallenge: doc.codeChallenge,
1498+ codeChallengeMethod: doc.codeChallengeMethod,
1499+ actorId: doc.actorId,
1500+ tokenClaims: doc.tokenClaims,
1501+ expiresAt: doc.expiresAt,
1502+ createdAt: doc.createdAt,
1503+ };
1504+ },
1505+});
1506+
1507+export const purgeExpiredAnonymousOauthAuthorizationCodes = internalMutation({
1508+ args: {
1509+ now: v.number(),
1510+ },
1511+ handler: async (ctx, args) => {
1512+ const expired = await ctx.db
1513+ .query("anonymousOauthCodes")
1514+ .withIndex("by_expires_at", (q) => q.lt("expiresAt", args.now))
1515+ .collect();
1516+
1517+ for (const doc of expired) {
1518+ await ctx.db.delete(doc._id);
1519+ }
1520+
1521+ return { purged: expired.length };
1522+ },
1523+});
1524+
1525+export const countAnonymousOauthAuthorizationCodes = internalQuery({
1526+ args: {},
1527+ handler: async (ctx) => {
1528+ const docs = await ctx.db.query("anonymousOauthCodes").collect();
1529+ return { count: docs.length };
1530+ },
1531+});
executor/packages/convex/executor.ts+3−28
@@ -222,35 +222,11 @@ export const resolveApprovalInternal = internalMutation({ · 221 unmodified lines
222222 },
223223 });
224224
225−export const appendRuntimeOutput = internalMutation({
226− args: {
227− runId: v.string(),
228− stream: v.union(v.literal("stdout"), v.literal("stderr")),
229− line: v.string(),
230− timestamp: v.optional(v.number()),
231− },
232− handler: async (ctx, args) => {
233− await ctx.runMutation(internal.database.createTaskEvent, {
234− taskId: args.runId,
235− eventName: "task",
236− type: args.stream === "stdout" ? "task.stdout" : "task.stderr",
237− payload: {
238− taskId: args.runId,
239− line: args.line,
240− timestamp: args.timestamp ?? Date.now(),
241− },
242− });
243−
244− return { ok: true as const };
245− },
246−});
247−
248225 export const completeRuntimeRun = internalMutation({
249226 args: {
250227 runId: v.string(),
251228 status: v.union(v.literal("completed"), v.literal("failed"), v.literal("timed_out"), v.literal("denied")),
252− stdout: v.optional(v.string()),
253− stderr: v.optional(v.string()),
229+ result: v.optional(v.any()),
254230 exitCode: v.optional(v.number()),
255231 error: v.optional(v.string()),
256232 durationMs: v.optional(v.number()),
@@ -265,11 +241,10 @@ export const completeRuntimeRun = internalMutation({ · 8 unmodified lines
265241 return { ok: true as const, alreadyFinal: true as const, task };
266242 }
267243
268− const finished = await ctx.runMutation(internal.database.markTaskFinished, {
244+ const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {
269245 taskId: args.runId,
270246 status: args.status,
271− stdout: args.stdout ?? "",
272− stderr: args.stderr ?? "",
247+ result: args.result,
273248 exitCode: args.exitCode,
274249 error: args.error,
275250 });
executor/packages/convex/executorNode.ts+39−1277
@@ -1,1062 +1,22 @@
11 "use node";
22
33 import { v } from "convex/values";
4−import { internal } from "./_generated/api";
5−import type { Id } from "./_generated/dataModel";
64 import { action, internalAction } from "./_generated/server";
7−import type { ActionCtx } from "./_generated/server";
8−import { InProcessExecutionAdapter } from "../core/src/adapters/in-process-execution-adapter";
9−import { resolveCredentialPayload } from "../core/src/credential-providers";
10−import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../core/src/execution-constants";
11−import { actorIdForAccount } from "../core/src/identity";
12−import { dispatchCodeWithCloudflareWorkerLoader } from "../core/src/runtimes/cloudflare-worker-loader-runtime";
13−import {
14− CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
15− isCloudflareWorkerLoaderConfigured,
16− isKnownRuntimeId,
17− LOCAL_BUN_RUNTIME_ID,
18−} from "../core/src/runtimes/runtime-catalog";
19−import { runCodeWithAdapter } from "../core/src/runtimes/runtime-core";
20−import { createDiscoverTool } from "../core/src/tool-discovery";
21−import {
22− compileExternalToolSource,
23− compileOpenApiToolSourceFromPrepared,
24− materializeCompiledToolSource,
25− materializeWorkspaceSnapshot,
26− parseGraphqlOperationPaths,
27− prepareOpenApiSpec,
28− type PreparedOpenApiSpec,
29− type CompiledToolSourceArtifact,
30− type ExternalToolSourceConfig,
31− type McpToolSourceConfig,
32− type OpenApiToolSourceConfig,
33− type GraphqlToolSourceConfig,
34− type WorkspaceToolSnapshot,
35−} from "../core/src/tool-sources";
36−import { DEFAULT_TOOLS } from "../core/src/tools";
375 import type {
38− AccessPolicyRecord,
39− CredentialScope,
40− PolicyDecision,
41− ResolvedToolCredential,
42− TaskRecord,
43− ToolCallRequest,
44− ToolCallRecord,
456 ToolCallResult,
46− ToolCredentialSpec,
47− ToolDefinition,
487 ToolDescriptor,
498 OpenApiSourceQuality,
50− ToolSourceRecord,
51− ToolRunContext,
9+ SourceAuthProfile,
5210 } from "../core/src/types";
53−import { asPayload, describeError } from "../core/src/utils";
54−
55−function createApprovalId(): string {
56− return `approval_${crypto.randomUUID()}`;
57−}
58−
59−function matchesToolPath(pattern: string, toolPath: string): boolean {
60− const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
61− const regex = new RegExp(`^${escaped}$`);
62− return regex.test(toolPath);
63−}
64−
65−function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientId?: string): number {
66− let score = 0;
67− if (policy.actorId && actorId && policy.actorId === actorId) score += 4;
68− if (policy.clientId && clientId && policy.clientId === clientId) score += 2;
69− score += Math.max(1, policy.toolPathPattern.replace(/\*/g, "").length);
70− score += policy.priority;
71− return score;
72−}
73−
74−function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
75− const signatureVersion = "v15";
76− const parts = sources
77− .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
78− .sort();
79− return `${signatureVersion}|${workspaceId}|${parts.join(",")}`;
80−}
81−
82−function normalizeToolPathSegment(segment: string): string {
83− return segment.toLowerCase().replace(/[^a-z0-9]/g, "");
84−}
85−
86−function normalizeToolPath(path: string): string {
87− return path
88− .split(".")
89− .filter(Boolean)
90− .map((segment) => normalizeToolPathSegment(segment))
91− .join(".");
92−}
93−
94−function levenshteinDistance(a: string, b: string): number {
95− if (a === b) return 0;
96− if (a.length === 0) return b.length;
97− if (b.length === 0) return a.length;
98−
99− const prev = new Array<number>(b.length + 1);
100− const curr = new Array<number>(b.length + 1);
101− for (let j = 0; j <= b.length; j++) prev[j] = j;
102−
103− for (let i = 1; i <= a.length; i++) {
104− curr[0] = i;
105− for (let j = 1; j <= b.length; j++) {
106− const cost = a[i - 1] === b[j - 1] ? 0 : 1;
107− curr[j] = Math.min(
108− curr[j - 1] + 1,
109− prev[j] + 1,
110− prev[j - 1] + cost,
111− );
112− }
113− for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
114− }
115−
116− return prev[b.length] ?? Math.max(a.length, b.length);
117−}
118−
119−function resolveAliasedToolPath(
120− requestedPath: string,
121− toolMap: Map<string, ToolDefinition>,
122−): string | null {
123− if (toolMap.has(requestedPath)) return requestedPath;
124−
125− const normalizedRequested = normalizeToolPath(requestedPath);
126− if (!normalizedRequested) return null;
127−
128− const matches: string[] = [];
129− for (const path of toolMap.keys()) {
130− if (normalizeToolPath(path) === normalizedRequested) {
131− matches.push(path);
132− }
133− }
134−
135− if (matches.length === 0) return null;
136− if (matches.length === 1) return matches[0]!;
137−
138− const requestedSegments = requestedPath.split(".").length;
139− const sameSegmentCount = matches.filter((path) => path.split(".").length === requestedSegments);
140− const pool = sameSegmentCount.length > 0 ? sameSegmentCount : matches;
141− return [...pool].sort((a, b) => a.length - b.length || a.localeCompare(b))[0] ?? null;
142−}
143−
144−function suggestToolPaths(
145− requestedPath: string,
146− toolMap: Map<string, ToolDefinition>,
147− limit = 3,
148−): string[] {
149− const normalizedRequested = normalizeToolPath(requestedPath);
150− const requestedSegments = normalizedRequested.split(".").filter(Boolean);
151− const requestedNamespace = requestedSegments[0] ?? "";
152−
153− const scored = [...toolMap.keys()]
154− .map((path) => {
155− const normalizedCandidate = normalizeToolPath(path);
156− const candidateSegments = normalizedCandidate.split(".").filter(Boolean);
157− const candidateNamespace = candidateSegments[0] ?? "";
158−
159− let score = -levenshteinDistance(normalizedRequested, normalizedCandidate);
160−
161− if (requestedNamespace && requestedNamespace === candidateNamespace) {
162− score += 6;
163− }
164−
165− if (normalizedCandidate.includes(normalizedRequested) || normalizedRequested.includes(normalizedCandidate)) {
166− score += 3;
167− }
168−
169− const sharedPrefix = Math.min(requestedSegments.length, candidateSegments.length);
170− let prefixMatches = 0;
171− for (let i = 0; i < sharedPrefix; i++) {
172− if (requestedSegments[i] !== candidateSegments[i]) break;
173− prefixMatches += 1;
174− }
175− score += prefixMatches * 2;
176−
177− return { path, score };
178− })
179− .sort((a, b) => b.score - a.score)
180− .slice(0, limit)
181− .map((item) => item.path);
182−
183− return scored;
184−}
185−
186−function normalizeExternalToolSource(raw: {
187− id: string;
188− type: ToolSourceRecord["type"];
189− name: string;
190− config: Record<string, unknown>;
191−}): ExternalToolSourceConfig {
192− const config = raw.config;
193−
194− if (raw.type === "mcp") {
195− if (typeof config.url !== "string" || config.url.trim().length === 0) {
196− throw new Error(`MCP source '${raw.name}' missing url`);
197− }
198−
199− if (
200− config.transport !== undefined
201− && config.transport !== "sse"
202− && config.transport !== "streamable-http"
203− ) {
204− throw new Error(`MCP source '${raw.name}' has invalid transport`);
205− }
206−
207− if (config.queryParams !== undefined) {
208− const queryParams = config.queryParams;
209− if (!queryParams || typeof queryParams !== "object" || Array.isArray(queryParams)) {
210− throw new Error(`MCP source '${raw.name}' queryParams must be an object`);
211− }
212−
213− for (const value of Object.values(queryParams as Record<string, unknown>)) {
214− if (typeof value !== "string") {
215− throw new Error(`MCP source '${raw.name}' queryParams values must be strings`);
216− }
217− }
218− }
219−
220− const result: McpToolSourceConfig = {
221− type: "mcp",
222− name: raw.name,
223− sourceId: raw.id,
224− sourceKey: `source:${raw.id}`,
225− url: config.url,
226− transport: config.transport as McpToolSourceConfig["transport"],
227− queryParams: config.queryParams as McpToolSourceConfig["queryParams"],
228− defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"],
229− overrides: config.overrides as McpToolSourceConfig["overrides"],
230− };
231− return result;
232− }
233−
234− if (raw.type === "graphql") {
235− if (typeof config.endpoint !== "string" || config.endpoint.trim().length === 0) {
236− throw new Error(`GraphQL source '${raw.name}' missing endpoint`);
237− }
238−
239− const result: GraphqlToolSourceConfig = {
240− type: "graphql",
241− name: raw.name,
242− sourceId: raw.id,
243− sourceKey: `source:${raw.id}`,
244− endpoint: config.endpoint,
245− schema: config.schema as GraphqlToolSourceConfig["schema"],
246− auth: config.auth as GraphqlToolSourceConfig["auth"],
247− defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"],
248− defaultMutationApproval: config.defaultMutationApproval as GraphqlToolSourceConfig["defaultMutationApproval"],
249− overrides: config.overrides as GraphqlToolSourceConfig["overrides"],
250− };
251− return result;
252− }
253−
254− const spec = config.spec;
255− if (typeof spec !== "string" && (typeof spec !== "object" || spec === null)) {
256− throw new Error(`OpenAPI source '${raw.name}' missing spec`);
257− }
258−
259− const result: OpenApiToolSourceConfig = {
260− type: "openapi",
261− name: raw.name,
262− sourceId: raw.id,
263− sourceKey: `source:${raw.id}`,
264− spec: spec as OpenApiToolSourceConfig["spec"],
265− baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"],
266− auth: config.auth as OpenApiToolSourceConfig["auth"],
267− defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"],
268− defaultWriteApproval: config.defaultWriteApproval as OpenApiToolSourceConfig["defaultWriteApproval"],
269− overrides: config.overrides as OpenApiToolSourceConfig["overrides"],
270− };
271− return result;
272−}
273−
274−const baseTools = new Map<string, ToolDefinition>(DEFAULT_TOOLS.map((tool) => [tool.path, tool]));
275−interface DtsStorageEntry {
276− sourceKey: string;
277− storageId: Id<"_storage">;
278−}
279−
280−const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
281−
282−/** Cache version — bump when PreparedOpenApiSpec shape changes. */
283−const OPENAPI_CACHE_VERSION = "v15";
284−
285−async function publish(
286− ctx: ActionCtx,
287− taskId: string,
288− eventName: "task" | "approval",
289− type: string,
290− payload: Record<string, unknown>,
291−): Promise<void> {
292− await ctx.runMutation(internal.database.createTaskEvent, {
293− taskId,
294− eventName,
295− type,
296− payload,
297− });
298−}
299−
300−/**
301− * Load a prepared OpenAPI spec, using Convex file storage as a persistent cache.
302− *
303− * Flow:
304− * 1. Check the `openApiSpecCache` table for a valid entry → fetch blob from storage.
305− * 2. On miss: prepare the spec from scratch, store the JSON blob, write the metadata row.
306− *
307− * No size limits — Convex file storage handles multi-MB specs without issue.
308− */
309−async function loadCachedOpenApiSpec(
310− ctx: ActionCtx,
311− specUrl: string,
312− sourceName: string,
313−): Promise<PreparedOpenApiSpec> {
314− // 1. Persistent cache (Convex table + file storage)
315− try {
316− const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, {
317− specUrl,
318− version: OPENAPI_CACHE_VERSION,
319− maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS,
320− });
321−
322− if (entry) {
323− const blob = await ctx.storage.get(entry.storageId);
324− if (blob) {
325− const json = await blob.text();
326− return JSON.parse(json) as PreparedOpenApiSpec;
327− }
328− // Blob missing (deleted externally?) — fall through to re-prepare
329− }
330− } catch (error) {
331− // Cache read failed — log and fall through to fresh preparation
332− const message = error instanceof Error ? error.message : String(error);
333− console.warn(`[executor] OpenAPI cache read failed for '${sourceName}': ${message}`);
334− }
335−
336− // 2. Cache miss — prepare from scratch
337− const prepared = await prepareOpenApiSpec(specUrl, sourceName);
338−
339− // Store in file storage + metadata table (best-effort, don't block on failure)
340− try {
341− const json = JSON.stringify(prepared);
342− const blob = new Blob([json], { type: "application/json" });
343− const storageId = await ctx.storage.store(blob);
344− await ctx.runMutation(internal.openApiSpecCache.putEntry, {
345− specUrl,
346− version: OPENAPI_CACHE_VERSION,
347− storageId,
348− sizeBytes: json.length,
349− });
350− } catch (error) {
351− const message = error instanceof Error ? error.message : String(error);
352− console.warn(`[executor] OpenAPI cache write failed for '${sourceName}': ${message}`);
353− }
354−
355− return prepared;
356−}
357−
358−async function loadSourceArtifact(
359− ctx: ActionCtx,
360− source: ExternalToolSourceConfig,
361−): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> {
362− if (source.type === "openapi" && typeof source.spec === "string") {
363− try {
364− const prepared = await loadCachedOpenApiSpec(ctx, source.spec, source.name);
365− const artifact = compileOpenApiToolSourceFromPrepared(source, prepared);
366− const warnings = (prepared.warnings ?? []).map(
367− (warning) => `Source '${source.name}': ${warning}`,
368− );
369− return { artifact, warnings };
370− } catch (error) {
371− const message = error instanceof Error ? error.message : String(error);
372− return {
373− artifact: undefined,
374− warnings: [`Failed to load openapi source '${source.name}': ${message}`],
375− };
376− }
377− }
378−
379− try {
380− const artifact = await compileExternalToolSource(source);
381− return { artifact, warnings: [] };
382− } catch (error) {
383− const message = error instanceof Error ? error.message : String(error);
384− return {
385− artifact: undefined,
386− warnings: [`Failed to load ${source.type} source '${source.name}': ${message}`],
387− };
388− }
389−}
390−
391−interface WorkspaceToolsResult {
392− tools: Map<string, ToolDefinition>;
393− warnings: string[];
394− dtsStorageIds: DtsStorageEntry[];
395−}
396−
397−async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspaces">): Promise<WorkspaceToolsResult> {
398− const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))
399− .filter((source: { enabled: boolean }) => source.enabled);
400− const signature = sourceSignature(workspaceId, sources);
401−
402− // ── Layer 1: Persistent workspace cache (Convex file storage) ──────
403− try {
404− const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, {
405− workspaceId,
406− signature,
407− });
408−
409− if (cacheEntry) {
410− const blob = await ctx.storage.get(cacheEntry.storageId);
411− if (blob) {
412− const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;
413− const restored = materializeWorkspaceSnapshot(snapshot);
414−
415− const merged = new Map<string, ToolDefinition>();
416− for (const tool of baseTools.values()) {
417− if (tool.path === "discover") continue;
418− merged.set(tool.path, tool);
419− }
420− for (const tool of restored) {
421− if (tool.path === "discover") continue;
422− merged.set(tool.path, tool);
423− }
424− // Recreate discover tool (it captures the full tool list in a closure)
425− const discover = createDiscoverTool([...merged.values()]);
426− merged.set(discover.path, discover);
427−
428− const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[];
429−
430− return { tools: merged, warnings: snapshot.warnings, dtsStorageIds };
431− }
432− }
433− } catch (error) {
434− const msg = error instanceof Error ? error.message : String(error);
435− console.warn(`[executor] workspace tool cache read failed for '${workspaceId}': ${msg}`);
436− }
437−
438− // ── Layer 2: Full rebuild from sources ─────────────────────────────
439− const configs: ExternalToolSourceConfig[] = [];
440− const warnings: string[] = [];
441− for (const source of sources) {
442− try {
443− configs.push(normalizeExternalToolSource(source));
444− } catch (error) {
445− const message = error instanceof Error ? error.message : String(error);
446− warnings.push(`Source '${source.name}': ${message}`);
447− }
448− }
449−
450− const loadedSources = await Promise.all(configs.map((config) => loadSourceArtifact(ctx, config)));
451− const externalArtifacts = loadedSources
452− .map((loaded) => loaded.artifact)
453− .filter((artifact): artifact is CompiledToolSourceArtifact => Boolean(artifact));
454− const externalTools = externalArtifacts.flatMap((artifact) => materializeCompiledToolSource(artifact));
455− warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings));
456−
457− const merged = new Map<string, ToolDefinition>();
458− for (const tool of baseTools.values()) {
459− if (tool.path === "discover") continue;
460− merged.set(tool.path, tool);
461− }
462− for (const tool of externalTools) {
463− merged.set(tool.path, tool);
464− }
465−
466− const discover = createDiscoverTool([...merged.values()]);
467− merged.set(discover.path, discover);
468−
469− // ── Write to persistent workspace cache (best-effort) ─────────────
470− let dtsStorageIds: DtsStorageEntry[] = [];
471− try {
472− const allTools = [...merged.values()];
473−
474− // Extract and store .d.ts blobs per source (too large for action responses)
475− const seenDtsSources = new Set<string>();
476− const dtsEntries: { sourceKey: string; content: string }[] = [];
477− for (const artifact of externalArtifacts) {
478− for (const tool of artifact.tools) {
479− if (tool.metadata?.sourceDts && tool.source && !seenDtsSources.has(tool.source)) {
480− seenDtsSources.add(tool.source);
481− dtsEntries.push({ sourceKey: tool.source, content: tool.metadata.sourceDts });
482− }
483− }
484− }
485−
486− // Store .d.ts blobs in parallel
487− const storedDts = await Promise.all(
488− dtsEntries.map(async (entry) => {
489− const dtsBlob = new Blob([entry.content], { type: "text/plain" });
490− const sid = await ctx.storage.store(dtsBlob);
491− return { sourceKey: entry.sourceKey, storageId: sid };
492− }),
493− );
494− dtsStorageIds = storedDts;
495−
496− // Strip sourceDts from artifacts (it's stored separately)
497− const sanitizedArtifacts: CompiledToolSourceArtifact[] = externalArtifacts.map((artifact) => ({
498− ...artifact,
499− tools: artifact.tools.map((tool) => {
500− if (!tool.metadata?.sourceDts) return tool;
501− const metadata = { ...tool.metadata };
502− delete (metadata as Record<string, unknown>).sourceDts;
503− return { ...tool, metadata };
504− }),
505− }));
506−
507− const snapshot: WorkspaceToolSnapshot = {
508− version: "v2",
509− externalArtifacts: sanitizedArtifacts,
510− warnings,
511− };
512−
513− const json = JSON.stringify(snapshot);
514− const blob = new Blob([json], { type: "application/json" });
515− const storageId = await ctx.storage.store(blob);
516− await ctx.runMutation(internal.workspaceToolCache.putEntry, {
517− workspaceId,
518− signature,
519− storageId,
520− dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })),
521− toolCount: allTools.length,
522− sizeBytes: json.length,
523− });
524− } catch (error) {
525− const msg = error instanceof Error ? error.message : String(error);
526− console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`);
527− }
528−
529− return { tools: merged, warnings, dtsStorageIds };
530−}
531−
532−function getDecisionForContext(
533− tool: ToolDefinition,
534− context: { workspaceId: string; actorId?: string; clientId?: string },
535− policies: AccessPolicyRecord[],
536−): PolicyDecision {
537− // `discover` is always available so models can find allowed tools dynamically.
538− // The discover implementation still filters returned results via context.isToolAllowed.
539− if (tool.path === "discover") {
540− return "allow";
541− }
542−
543− const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow";
544− const candidates = policies
545− .filter((policy) => {
546− if (policy.actorId && policy.actorId !== context.actorId) return false;
547− if (policy.clientId && policy.clientId !== context.clientId) return false;
548− return matchesToolPath(policy.toolPathPattern, tool.path);
549− })
550− .sort(
551− (a, b) =>
552− policySpecificity(b, context.actorId, context.clientId)
553− - policySpecificity(a, context.actorId, context.clientId),
554− );
555−
556− return candidates[0]?.decision ?? defaultDecision;
557−}
558−
559−function getToolDecision(task: TaskRecord, tool: ToolDefinition, policies: AccessPolicyRecord[]): PolicyDecision {
560− return getDecisionForContext(
561− tool,
562− {
563− workspaceId: task.workspaceId,
564− actorId: task.actorId,
565− clientId: task.clientId,
566− },
567− policies,
568− );
569−}
570−
571−function toToolDescriptor(tool: ToolDefinition, approval: "auto" | "required"): ToolDescriptor {
572− return {
573− path: tool.path,
574− description: tool.description,
575− approval,
576− source: tool.source,
577− argsType: tool.metadata?.displayArgsType ?? tool.metadata?.argsType,
578− returnsType: tool.metadata?.displayReturnsType ?? tool.metadata?.returnsType,
579− strictArgsType: tool.metadata?.argsType,
580− strictReturnsType: tool.metadata?.returnsType,
581− argPreviewKeys: tool.metadata?.argPreviewKeys,
582− operationId: tool.metadata?.operationId,
583− // Note: sourceDts is NOT included — it's too large to send over the wire.
584− // Monaco fetches .d.ts blobs separately via Convex storage URLs.
585− };
586−}
587−
588−function computeOpenApiSourceQuality(
589− workspaceTools: Map<string, ToolDefinition>,
590−): Record<string, OpenApiSourceQuality> {
591− const grouped = new Map<string, ToolDefinition[]>();
592−
593− for (const tool of workspaceTools.values()) {
594− const sourceKey = tool.source;
595− if (!sourceKey || !sourceKey.startsWith("openapi:")) continue;
596− const list = grouped.get(sourceKey) ?? [];
597− list.push(tool);
598− grouped.set(sourceKey, list);
599− }
600−
601− const qualityBySource: Record<string, OpenApiSourceQuality> = {};
602−
603− for (const [sourceKey, tools] of grouped.entries()) {
604− const toolCount = tools.length;
605−
606− let unknownArgsCount = 0;
607− let unknownReturnsCount = 0;
608− let partialUnknownArgsCount = 0;
609− let partialUnknownReturnsCount = 0;
610−
611− for (const tool of tools) {
612− const argsType = tool.metadata?.argsType?.trim() ?? "";
613− const returnsType = tool.metadata?.returnsType?.trim() ?? "";
614−
615− if (!argsType || argsType === "Record<string, unknown>") {
616− unknownArgsCount += 1;
617− }
618− if (!returnsType || returnsType === "unknown") {
619− unknownReturnsCount += 1;
620− }
621− if (argsType.includes("unknown")) {
622− partialUnknownArgsCount += 1;
623− }
624− if (returnsType.includes("unknown")) {
625− partialUnknownReturnsCount += 1;
626− }
627− }
628−
629− const argsQuality = toolCount > 0 ? (toolCount - unknownArgsCount) / toolCount : 1;
630− const returnsQuality = toolCount > 0 ? (toolCount - unknownReturnsCount) / toolCount : 1;
631− const overallQuality = (argsQuality + returnsQuality) / 2;
632−
633− qualityBySource[sourceKey] = {
634− sourceKey,
635− toolCount,
636− unknownArgsCount,
637− unknownReturnsCount,
638− partialUnknownArgsCount,
639− partialUnknownReturnsCount,
640− argsQuality,
641− returnsQuality,
642− overallQuality,
643− };
644− }
645−
646− return qualityBySource;
647−}
648−
649−function listVisibleToolDescriptors(
650− workspaceTools: Map<string, ToolDefinition>,
651− context: { workspaceId: string; actorId?: string; clientId?: string },
652− policies: AccessPolicyRecord[],
653−): ToolDescriptor[] {
654− const all = [...workspaceTools.values()];
655−
656− return all
657− .filter((tool) => {
658− const decision = getDecisionForContext(tool, context, policies);
659− return decision !== "deny";
660− })
661− .map((tool) => {
662− const decision = getDecisionForContext(tool, context, policies);
663− return toToolDescriptor(tool, decision === "require_approval" ? "required" : "auto");
664− });
665−}
666−
667−async function listToolsForContext(
668− ctx: ActionCtx,
669− context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
670−): Promise<ToolDescriptor[]> {
671− const [result, policies] = await Promise.all([
672− getWorkspaceTools(ctx, context.workspaceId),
673− ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }),
674− ]);
675− const typedPolicies = policies as AccessPolicyRecord[];
676−
677− return listVisibleToolDescriptors(result.tools, context, typedPolicies);
678−}
679−
680−async function listToolsWithWarningsForContext(
681− ctx: ActionCtx,
682− context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
683−): Promise<{
684− tools: ToolDescriptor[];
685− warnings: string[];
686− dtsUrls: Record<string, string>;
687− sourceQuality: Record<string, OpenApiSourceQuality>;
688−}> {
689− const [result, policies] = await Promise.all([
690− getWorkspaceTools(ctx, context.workspaceId),
691− ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }),
692− ]);
693− const typedPolicies = policies as AccessPolicyRecord[];
694− const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies);
695− const sourceQuality = computeOpenApiSourceQuality(result.tools);
696−
697− // Generate download URLs for .d.ts blobs
698− const dtsUrls: Record<string, string> = {};
699− for (const entry of result.dtsStorageIds) {
700− try {
701− const url = await ctx.storage.getUrl(entry.storageId);
702− if (url) dtsUrls[entry.sourceKey] = url;
703− } catch {
704− // Storage ID may be stale — skip
705− }
706− }
707−
708− return {
709− tools,
710− warnings: result.warnings,
711− dtsUrls,
712− sourceQuality,
713− };
714−}
715−
716−function isToolAllowedForTask(
717− task: TaskRecord,
718− toolPath: string,
719− workspaceTools: Map<string, ToolDefinition>,
720− policies: AccessPolicyRecord[],
721−): boolean {
722− const tool = workspaceTools.get(toolPath);
723− if (!tool) return false;
724− return getToolDecision(task, tool, policies) !== "deny";
725−}
726−
727−async function resolveCredentialHeaders(
728− ctx: ActionCtx,
729− spec: ToolCredentialSpec,
730− task: TaskRecord,
731−): Promise<ResolvedToolCredential | null> {
732− const record = await ctx.runQuery(internal.database.resolveCredential, {
733− workspaceId: task.workspaceId,
734− sourceKey: spec.sourceKey,
735− scope: spec.mode as CredentialScope,
736− actorId: task.actorId,
737− });
738−
739− const source = record
740− ? await resolveCredentialPayload(record)
741− : spec.staticSecretJson ?? null;
742− if (!source) {
743− return null;
744− }
745−
746− const headers: Record<string, string> = {};
747− if (spec.authType === "bearer") {
748− const token = String((source as Record<string, unknown>).token ?? "").trim();
749− if (token) headers.authorization = `Bearer ${token}`;
750− } else if (spec.authType === "apiKey") {
751− const headerName = spec.headerName ?? String((source as Record<string, unknown>).headerName ?? "x-api-key");
752− const value = String((source as Record<string, unknown>).value ?? (source as Record<string, unknown>).token ?? "").trim();
753− if (value) headers[headerName] = value;
754− } else if (spec.authType === "basic") {
755− const username = String((source as Record<string, unknown>).username ?? "");
756− const password = String((source as Record<string, unknown>).password ?? "");
757− if (username || password) {
758− const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
759− headers.authorization = `Basic ${encoded}`;
760− }
761− }
762−
763− if (Object.keys(headers).length === 0) {
764− return null;
765− }
766−
767− return {
768− sourceKey: spec.sourceKey,
769− mode: spec.mode,
770− headers,
771− };
772−}
773−
774−function getGraphqlDecision(
775− task: TaskRecord,
776− tool: ToolDefinition,
777− input: unknown,
778− workspaceTools: Map<string, ToolDefinition>,
779− policies: AccessPolicyRecord[],
780−): { decision: PolicyDecision; effectivePaths: string[] } {
781− const sourceName = tool._graphqlSource!;
782− const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
783− const queryString = typeof payload.query === "string" ? payload.query : "";
784−
785− if (!queryString.trim()) {
786− return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };
787− }
788−
789− const { fieldPaths } = parseGraphqlOperationPaths(sourceName, queryString);
790− if (fieldPaths.length === 0) {
791− return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };
792− }
793−
794− let worstDecision: PolicyDecision = "allow";
795−
796− for (const fieldPath of fieldPaths) {
797− const pseudoTool = workspaceTools.get(fieldPath);
798− const fieldDecision = pseudoTool
799− ? getDecisionForContext(
800− pseudoTool,
801− {
802− workspaceId: task.workspaceId,
803− actorId: task.actorId,
804− clientId: task.clientId,
805− },
806− policies,
807− )
808− : getDecisionForContext(
809− { ...tool, path: fieldPath, approval: fieldPath.includes(".mutation.") ? "required" : "auto" },
810− {
811− workspaceId: task.workspaceId,
812− actorId: task.actorId,
813− clientId: task.clientId,
814− },
815− policies,
816− );
817−
818− if (fieldDecision === "deny") {
819− worstDecision = "deny";
820− break;
821− }
822− if (fieldDecision === "require_approval") {
823− worstDecision = "require_approval";
824− }
825− }
826−
827− return { decision: worstDecision, effectivePaths: fieldPaths };
828−}
829−
830−async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {
831− const { toolPath, input, callId } = call;
832− const persistedCall = (await ctx.runMutation(internal.database.upsertToolCallRequested, {
833− taskId: task.id,
834− callId,
835− workspaceId: task.workspaceId,
836− toolPath,
837− input,
838− })) as ToolCallRecord;
839−
840− if (persistedCall.status === "completed") {
841− return persistedCall.output;
842− }
843−
844− if (persistedCall.status === "failed") {
845− throw new Error(persistedCall.error ?? `Tool call failed: ${callId}`);
846− }
847−
848− if (persistedCall.status === "denied") {
849− throw new Error(`${APPROVAL_DENIED_PREFIX}${persistedCall.error ?? persistedCall.toolPath}`);
850− }
851−
852− const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
853− const typedPolicies = policies as AccessPolicyRecord[];
854−
855− let workspaceTools: Map<string, ToolDefinition> | undefined;
856− let resolvedToolPath = toolPath;
857− let tool = baseTools.get(toolPath);
858− if (!tool) {
859− const result = await getWorkspaceTools(ctx, task.workspaceId);
860− workspaceTools = result.tools;
861− tool = workspaceTools.get(toolPath);
862−
863− if (!tool) {
864− const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools);
865− if (aliasedPath) {
866− resolvedToolPath = aliasedPath;
867− tool = workspaceTools.get(aliasedPath);
868− }
869− }
870− }
871−
872− if (!tool) {
873− const availableTools = workspaceTools ?? baseTools;
874− const suggestions = suggestToolPaths(toolPath, availableTools);
875− const suggestionText = suggestions.length > 0
876− ? `\nDid you mean: ${suggestions.map((path) => `tools.${path}`).join(", ")}`
877− : "";
878− throw new Error(`Unknown tool: ${toolPath}${suggestionText}`);
879− }
880−
881− let decision: PolicyDecision;
882− let effectiveToolPath = resolvedToolPath;
883− if (tool._graphqlSource) {
884− if (!workspaceTools) {
885− const result = await getWorkspaceTools(ctx, task.workspaceId);
886− workspaceTools = result.tools;
887− }
888− const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies);
889− decision = result.decision;
890− if (result.effectivePaths.length > 0) {
891− effectiveToolPath = result.effectivePaths.join(", ");
892− }
893− } else {
894− decision = getToolDecision(task, tool, typedPolicies);
895− }
896−
897− const publishToolStarted = persistedCall.status === "requested";
898−
899− if (decision === "deny") {
900− const deniedMessage = `${effectiveToolPath} (policy denied)`;
901− await ctx.runMutation(internal.database.finishToolCall, {
902− taskId: task.id,
903− callId,
904− status: "denied",
905− error: deniedMessage,
906− });
907− await publish(ctx, task.id, "task", "tool.call.denied", {
908− taskId: task.id,
909− callId,
910− toolPath: effectiveToolPath,
911− reason: "policy_deny",
912− });
913− throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
914− }
915−
916− let credential: ResolvedToolCredential | undefined;
917− if (tool.credential) {
918− const resolved = await resolveCredentialHeaders(ctx, tool.credential, task);
919− if (!resolved) {
920− throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`);
921− }
922− credential = resolved;
923− }
924−
925− if (publishToolStarted) {
926− await publish(ctx, task.id, "task", "tool.call.started", {
927− taskId: task.id,
928− callId,
929− toolPath: effectiveToolPath,
930− approval: decision === "require_approval" ? "required" : "auto",
931− input: asPayload(input),
932− });
933− }
934−
935− let approvalSatisfied = false;
936− if (persistedCall.approvalId) {
937− const existingApproval = await ctx.runQuery(internal.database.getApproval, {
938− approvalId: persistedCall.approvalId,
939− });
940− if (!existingApproval) {
941− throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`);
942− }
943−
944− if (existingApproval.status === "pending") {
945− throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`);
946− }
947−
948− if (existingApproval.status === "denied") {
949− const deniedMessage = `${effectiveToolPath} (${existingApproval.id})`;
950− await ctx.runMutation(internal.database.finishToolCall, {
951− taskId: task.id,
952− callId,
953− status: "denied",
954− error: deniedMessage,
955− });
956− await publish(ctx, task.id, "task", "tool.call.denied", {
957− taskId: task.id,
958− callId,
959− toolPath: effectiveToolPath,
960− approvalId: existingApproval.id,
961− });
962− throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
963− }
964−
965− approvalSatisfied = existingApproval.status === "approved";
966− }
967−
968− if (decision === "require_approval" && !approvalSatisfied) {
969− const approvalId = persistedCall.approvalId ?? createApprovalId();
970− let approval = await ctx.runQuery(internal.database.getApproval, {
971− approvalId,
972− });
973−
974− if (!approval) {
975− approval = await ctx.runMutation(internal.database.createApproval, {
976− id: approvalId,
977− taskId: task.id,
978− toolPath: effectiveToolPath,
979− input,
980− });
981−
982− await publish(ctx, task.id, "approval", "approval.requested", {
983− approvalId: approval.id,
984− taskId: task.id,
985− callId,
986− toolPath: approval.toolPath,
987− input: asPayload(approval.input),
988− createdAt: approval.createdAt,
989− });
990− }
991−
992− await ctx.runMutation(internal.database.setToolCallPendingApproval, {
993− taskId: task.id,
994− callId,
995− approvalId: approval.id,
996− });
997−
998− if (approval.status === "pending") {
999− throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`);
1000− }
1001−
1002− if (approval.status === "denied") {
1003− const deniedMessage = `${effectiveToolPath} (${approval.id})`;
1004− await ctx.runMutation(internal.database.finishToolCall, {
1005− taskId: task.id,
1006− callId,
1007− status: "denied",
1008− error: deniedMessage,
1009− });
1010− await publish(ctx, task.id, "task", "tool.call.denied", {
1011− taskId: task.id,
1012− callId,
1013− toolPath: effectiveToolPath,
1014− approvalId: approval.id,
1015− });
1016− throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
1017− }
1018− }
1019−
1020− try {
1021− const context: ToolRunContext = {
1022− taskId: task.id,
1023− workspaceId: task.workspaceId,
1024− actorId: task.actorId,
1025− clientId: task.clientId,
1026− credential,
1027− isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies),
1028− };
1029− const value = await tool.run(input, context);
1030− await ctx.runMutation(internal.database.finishToolCall, {
1031− taskId: task.id,
1032− callId,
1033− status: "completed",
1034− output: value,
1035− });
1036− await publish(ctx, task.id, "task", "tool.call.completed", {
1037− taskId: task.id,
1038− callId,
1039− toolPath: effectiveToolPath,
1040− output: asPayload(value),
1041− });
1042− return value;
1043− } catch (error) {
1044− const message = describeError(error);
1045− await ctx.runMutation(internal.database.finishToolCall, {
1046− taskId: task.id,
1047− callId,
1048− status: "failed",
1049− error: message,
1050− });
1051− await publish(ctx, task.id, "task", "tool.call.failed", {
1052− taskId: task.id,
1053− callId,
1054− toolPath: effectiveToolPath,
1055− error: message,
1056− });
1057− throw error;
1058− }
1059−}
11+import { requireCanonicalActor } from "./executor_node/actor_auth";
12+import {
13+ listToolsForContext,
14+ listToolsWithWarningsForContext,
15+ loadDtsUrls,
16+ loadWorkspaceDtsStorageIds,
17+} from "./executor_node/workspace_tools";
18+import { runQueuedTask } from "./executor_node/task_runner";
19+import { handleExternalToolCallRequest } from "./executor_node/external_tool_call";
106020
106121 export const listToolsWithWarnings = action({
106222 args: {
@@ -1073,19 +33,13 @@ export const listToolsWithWarnings = action({ · 10 unmodified lines
107333 warnings: string[];
107434 dtsUrls: Record<string, string>;
107535 sourceQuality: Record<string, OpenApiSourceQuality>;
36+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
107637 }> => {
1077− const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {
38+ const canonicalActorId = await requireCanonicalActor(ctx, {
107839 workspaceId: args.workspaceId,
107940 sessionId: args.sessionId,
41+ actorId: args.actorId,
108042 });
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− }
108943
109044 return await listToolsWithWarningsForContext(ctx, {
109145 workspaceId: args.workspaceId,
@@ -1095,6 +49,29 @@ export const listToolsWithWarnings = action({ · 3 unmodified lines
109549 },
109650 });
109751
52+export const listToolDtsUrls = action({
53+ args: {
54+ workspaceId: v.id("workspaces"),
55+ actorId: v.optional(v.string()),
56+ sessionId: v.optional(v.string()),
57+ },
58+ handler: async (
59+ ctx,
60+ args,
61+ ): Promise<{
62+ dtsUrls: Record<string, string>;
63+ }> => {
64+ await requireCanonicalActor(ctx, {
65+ workspaceId: args.workspaceId,
66+ sessionId: args.sessionId,
67+ actorId: args.actorId,
68+ });
69+ const dtsStorageIds = await loadWorkspaceDtsStorageIds(ctx, args.workspaceId);
70+ const dtsUrls = await loadDtsUrls(ctx, dtsStorageIds);
71+ return { dtsUrls };
72+ },
73+});
74+
109875 export const listToolsInternal = internalAction({
109976 args: {
110077 workspaceId: v.id("workspaces"),
@@ -1120,6 +97,7 @@ export const listToolsWithWarningsInternal = internalAction({ · 19 unmodified lines
112097 warnings: string[];
112198 dtsUrls: Record<string, string>;
112299 sourceQuality: Record<string, OpenApiSourceQuality>;
100+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
1123101 }> => {
1124102 return await listToolsWithWarningsForContext(ctx, args);
1125103 },
@@ -1132,226 +110,10 @@ export const handleExternalToolCall = internalAction({ · 6 unmodified lines
1132110 toolPath: v.string(),
1133111 input: v.optional(v.any()),
1134112 },
1135− handler: async (ctx, args): Promise<ToolCallResult> => {
1136− const task = (await ctx.runQuery(internal.database.getTask, {
1137− taskId: args.runId,
1138− })) as TaskRecord | null;
1139− if (!task) {
1140− return {
1141− ok: false,
1142− kind: "failed",
1143− error: `Run not found: ${args.runId}`,
1144− };
1145− }
1146−
1147− try {
1148− const value = await invokeTool(ctx, task, {
1149− runId: args.runId,
1150− callId: args.callId,
1151− toolPath: args.toolPath,
1152− input: args.input ?? {},
1153− });
1154− return { ok: true, value };
1155− } catch (error) {
1156− const message = describeError(error);
1157− if (message.startsWith(APPROVAL_PENDING_PREFIX)) {
1158− const approvalId = message.replace(APPROVAL_PENDING_PREFIX, "").trim();
1159− return {
1160− ok: false,
1161− kind: "pending",
1162− approvalId,
1163− retryAfterMs: 500,
1164− error: "Approval pending",
1165− };
1166− }
1167− if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
1168− return {
1169− ok: false,
1170− kind: "denied",
1171− error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(),
1172− };
1173− }
1174−
1175− return {
1176− ok: false,
1177− kind: "failed",
1178− error: message,
1179− };
1180− }
1181− },
113+ handler: async (ctx, args): Promise<ToolCallResult> => await handleExternalToolCallRequest(ctx, args),
1182114 });
1183115
1184116 export const runTask = internalAction({
1185117 args: { taskId: v.string() },
1186− handler: async (ctx, args) => {
1187− const task = (await ctx.runQuery(internal.database.getTask, { taskId: args.taskId })) as TaskRecord | null;
1188− if (!task || task.status !== "queued") {
1189− return null;
1190− }
1191−
1192− if (!isKnownRuntimeId(task.runtimeId)) {
1193− const failed = await ctx.runMutation(internal.database.markTaskFinished, {
1194− taskId: args.taskId,
1195− status: "failed",
1196− stdout: "",
1197− stderr: "",
1198− error: `Runtime not found: ${task.runtimeId}`,
1199− });
1200−
1201− if (failed) {
1202− await publish(ctx, args.taskId, "task", "task.failed", {
1203− taskId: args.taskId,
1204− status: failed.status,
1205− error: failed.error,
1206− });
1207− }
1208− return null;
1209− }
1210−
1211− if (task.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID && !isCloudflareWorkerLoaderConfigured()) {
1212− const failed = await ctx.runMutation(internal.database.markTaskFinished, {
1213− taskId: args.taskId,
1214− status: "failed",
1215− stdout: "",
1216− stderr: "",
1217− error: `Runtime is not configured: ${task.runtimeId}`,
1218− });
1219−
1220− if (failed) {
1221− await publish(ctx, args.taskId, "task", "task.failed", {
1222− taskId: args.taskId,
1223− status: failed.status,
1224− error: failed.error,
1225− });
1226− }
1227− return null;
1228− }
1229−
1230− try {
1231− const running = (await ctx.runMutation(internal.database.markTaskRunning, {
1232− taskId: args.taskId,
1233− })) as TaskRecord | null;
1234− if (!running) {
1235− return null;
1236− }
1237−
1238− await publish(ctx, args.taskId, "task", "task.running", {
1239− taskId: args.taskId,
1240− status: running.status,
1241− startedAt: running.startedAt,
1242− });
1243−
1244− if (running.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID) {
1245− const dispatchResult = await dispatchCodeWithCloudflareWorkerLoader({
1246− taskId: args.taskId,
1247− code: running.code,
1248− timeoutMs: running.timeoutMs,
1249− });
1250−
1251− if (!dispatchResult.ok) {
1252− const failed = await ctx.runMutation(internal.database.markTaskFinished, {
1253− taskId: args.taskId,
1254− status: "failed",
1255− stdout: "",
1256− stderr: "",
1257− error: dispatchResult.error,
1258− });
1259−
1260− if (failed) {
1261− await publish(ctx, args.taskId, "task", "task.failed", {
1262− taskId: args.taskId,
1263− status: failed.status,
1264− error: failed.error,
1265− completedAt: failed.completedAt,
1266− });
1267− }
1268− return null;
1269− }
1270−
1271− await publish(ctx, args.taskId, "task", "task.dispatched", {
1272− taskId: args.taskId,
1273− runtimeId: running.runtimeId,
1274− dispatchId: dispatchResult.dispatchId,
1275− durationMs: dispatchResult.durationMs,
1276− });
1277− return null;
1278− }
1279−
1280− const runtimeResult = await (async () => {
1281− const adapter = new InProcessExecutionAdapter({
1282− runId: args.taskId,
1283− invokeTool: async (call) => await invokeTool(ctx, running, call),
1284− emitOutput: async (event) => {
1285− await ctx.runMutation(internal.executor.appendRuntimeOutput, {
1286− runId: event.runId,
1287− stream: event.stream,
1288− line: event.line,
1289− timestamp: event.timestamp,
1290− });
1291− },
1292− });
1293−
1294− return await runCodeWithAdapter(
1295− {
1296− taskId: args.taskId,
1297− code: running.code,
1298− timeoutMs: running.timeoutMs,
1299− },
1300− adapter,
1301− );
1302− })();
1303−
1304− const finished = await ctx.runMutation(internal.database.markTaskFinished, {
1305− taskId: args.taskId,
1306− status: runtimeResult.status,
1307− stdout: runtimeResult.stdout,
1308− stderr: runtimeResult.stderr,
1309− exitCode: runtimeResult.exitCode,
1310− error: runtimeResult.error,
1311− });
1312−
1313− if (!finished) {
1314− return null;
1315− }
1316−
1317− const terminalEvent =
1318− runtimeResult.status === "completed"
1319− ? "task.completed"
1320− : runtimeResult.status === "timed_out"
1321− ? "task.timed_out"
1322− : runtimeResult.status === "denied"
1323− ? "task.denied"
1324− : "task.failed";
1325−
1326− await publish(ctx, args.taskId, "task", terminalEvent, {
1327− taskId: args.taskId,
1328− status: finished.status,
1329− exitCode: finished.exitCode,
1330− durationMs: runtimeResult.durationMs,
1331− error: finished.error,
1332− completedAt: finished.completedAt,
1333− });
1334− } catch (error) {
1335− const message = describeError(error);
1336− const denied = message.startsWith(APPROVAL_DENIED_PREFIX);
1337− const finished = await ctx.runMutation(internal.database.markTaskFinished, {
1338− taskId: args.taskId,
1339− status: denied ? "denied" : "failed",
1340− stdout: "",
1341− stderr: "",
1342− error: denied ? message.replace(APPROVAL_DENIED_PREFIX, "") : message,
1343− });
1344−
1345− if (finished) {
1346− await publish(ctx, args.taskId, "task", denied ? "task.denied" : "task.failed", {
1347− taskId: args.taskId,
1348− status: finished.status,
1349− error: finished.error,
1350− completedAt: finished.completedAt,
1351− });
1352− }
1353− }
1354−
1355− return null;
1356− },
118+ handler: async (ctx, args) => await runQueuedTask(ctx, args),
1357119 });
executor/packages/convex/executor_node/actor_auth.ts+29−0
@@ -0,0 +1,29 @@
1+"use node";
2+
3+import type { ActionCtx } from "../_generated/server";
4+import type { Id } from "../_generated/dataModel";
5+import { internal } from "../_generated/api";
6+import { actorIdForAccount } from "../../core/src/identity";
7+
8+export async function requireCanonicalActor(
9+ ctx: ActionCtx,
10+ args: {
11+ workspaceId: Id<"workspaces">;
12+ sessionId?: string;
13+ actorId?: string;
14+ },
15+): Promise<string> {
16+ const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {
17+ workspaceId: args.workspaceId,
18+ sessionId: args.sessionId,
19+ });
20+ const canonicalActorId = actorIdForAccount({
21+ _id: access.accountId,
22+ provider: access.provider,
23+ providerAccountId: access.providerAccountId,
24+ });
25+ if (args.actorId && args.actorId !== canonicalActorId) {
26+ throw new Error("actorId must match the authenticated workspace actor");
27+ }
28+ return canonicalActorId;
29+}
executor/packages/convex/executor_node/events.ts+19−0
@@ -0,0 +1,19 @@
1+"use node";
2+
3+import type { ActionCtx } from "../_generated/server";
4+import { internal } from "../_generated/api";
5+
6+export async function publishTaskEvent(
7+ ctx: ActionCtx,
8+ taskId: string,
9+ eventName: "task" | "approval",
10+ type: string,
11+ payload: Record<string, unknown>,
12+): Promise<void> {
13+ await ctx.runMutation(internal.database.createTaskEvent, {
14+ taskId,
15+ eventName,
16+ type,
17+ payload,
18+ });
19+}
executor/packages/convex/executor_node/external_tool_call.ts+64−0
@@ -0,0 +1,64 @@
1+"use node";
2+
3+import type { ActionCtx } from "../_generated/server";
4+import { internal } from "../_generated/api";
5+import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants";
6+import type { TaskRecord, ToolCallResult } from "../../core/src/types";
7+import { describeError } from "../../core/src/utils";
8+import { invokeTool } from "./tool_invocation";
9+
10+export async function handleExternalToolCallRequest(
11+ ctx: ActionCtx,
12+ args: {
13+ runId: string;
14+ callId: string;
15+ toolPath: string;
16+ input?: unknown;
17+ },
18+): Promise<ToolCallResult> {
19+ const task = (await ctx.runQuery(internal.database.getTask, {
20+ taskId: args.runId,
21+ })) as TaskRecord | null;
22+ if (!task) {
23+ return {
24+ ok: false,
25+ kind: "failed",
26+ error: `Run not found: ${args.runId}`,
27+ };
28+ }
29+
30+ try {
31+ const value = await invokeTool(ctx, task, {
32+ runId: args.runId,
33+ callId: args.callId,
34+ toolPath: args.toolPath,
35+ input: args.input ?? {},
36+ });
37+ return { ok: true, value };
38+ } catch (error) {
39+ const message = describeError(error);
40+ if (message.startsWith(APPROVAL_PENDING_PREFIX)) {
41+ const approvalId = message.replace(APPROVAL_PENDING_PREFIX, "").trim();
42+ return {
43+ ok: false,
44+ kind: "pending",
45+ approvalId,
46+ retryAfterMs: 500,
47+ error: "Approval pending",
48+ };
49+ }
50+ if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
51+ return {
52+ ok: false,
53+ kind: "denied",
54+ error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(),
55+ };
56+ }
57+
58+ return {
59+ ok: false,
60+ kind: "failed",
61+ error: message,
62+ };
63+ }
64+}
executor/packages/convex/executor_node/policy.ts+75−0
@@ -0,0 +1,75 @@
1+"use node";
2+
3+import type {
4+ AccessPolicyRecord,
5+ PolicyDecision,
6+ TaskRecord,
7+ ToolDefinition,
8+} from "../../core/src/types";
9+
10+function matchesToolPath(pattern: string, toolPath: string): boolean {
11+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
12+ const regex = new RegExp(`^${escaped}$`);
13+ return regex.test(toolPath);
14+}
15+
16+function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientId?: string): number {
17+ let score = 0;
18+ if (policy.actorId && actorId && policy.actorId === actorId) score += 4;
19+ if (policy.clientId && clientId && policy.clientId === clientId) score += 2;
20+ score += Math.max(1, policy.toolPathPattern.replace(/\*/g, "").length);
21+ score += policy.priority;
22+ return score;
23+}
24+
25+export function getDecisionForContext(
26+ tool: ToolDefinition,
27+ context: { workspaceId: string; actorId?: string; clientId?: string },
28+ policies: AccessPolicyRecord[],
29+): PolicyDecision {
30+ if (tool.path === "discover") {
31+ return "allow";
32+ }
33+
34+ const defaultDecision: PolicyDecision = tool.approval === "required" ? "require_approval" : "allow";
35+ const candidates = policies
36+ .filter((policy) => {
37+ if (policy.actorId && policy.actorId !== context.actorId) return false;
38+ if (policy.clientId && policy.clientId !== context.clientId) return false;
39+ return matchesToolPath(policy.toolPathPattern, tool.path);
40+ })
41+ .sort(
42+ (a, b) =>
43+ policySpecificity(b, context.actorId, context.clientId)
44+ - policySpecificity(a, context.actorId, context.clientId),
45+ );
46+
47+ return candidates[0]?.decision ?? defaultDecision;
48+}
49+
50+export function getToolDecision(
51+ task: TaskRecord,
52+ tool: ToolDefinition,
53+ policies: AccessPolicyRecord[],
54+): PolicyDecision {
55+ return getDecisionForContext(
56+ tool,
57+ {
58+ workspaceId: task.workspaceId,
59+ actorId: task.actorId,
60+ clientId: task.clientId,
61+ },
62+ policies,
63+ );
64+}
65+
66+export function isToolAllowedForTask(
67+ task: TaskRecord,
68+ toolPath: string,
69+ workspaceTools: Map<string, ToolDefinition>,
70+ policies: AccessPolicyRecord[],
71+): boolean {
72+ const tool = workspaceTools.get(toolPath);
73+ if (!tool) return false;
74+ return getToolDecision(task, tool, policies) !== "deny";
75+}
executor/packages/convex/executor_node/task_runner.ts+175−0
@@ -0,0 +1,175 @@
1+"use node";
2+
3+import type { ActionCtx } from "../_generated/server";
4+import { internal } from "../_generated/api";
5+import { InProcessExecutionAdapter } from "../../core/src/adapters/in-process-execution-adapter";
6+import { APPROVAL_DENIED_PREFIX } from "../../core/src/execution-constants";
7+import { dispatchCodeWithCloudflareWorkerLoader } from "../../core/src/runtimes/cloudflare-worker-loader-runtime";
8+import {
9+ CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
10+ isCloudflareWorkerLoaderConfigured,
11+ isKnownRuntimeId,
12+} from "../../core/src/runtimes/runtime-catalog";
13+import { runCodeWithAdapter } from "../../core/src/runtimes/runtime-core";
14+import type { TaskRecord } from "../../core/src/types";
15+import { describeError } from "../../core/src/utils";
16+import { publishTaskEvent } from "./events";
17+import { invokeTool } from "./tool_invocation";
18+
19+export async function runQueuedTask(
20+ ctx: ActionCtx,
21+ args: { taskId: string },
22+): Promise<null> {
23+ const task = (await ctx.runQuery(internal.database.getTask, { taskId: args.taskId })) as TaskRecord | null;
24+ if (!task || task.status !== "queued") {
25+ return null;
26+ }
27+
28+ if (!isKnownRuntimeId(task.runtimeId)) {
29+ const failed = await ctx.runMutation(internal.database.markTaskFinished as any, {
30+ taskId: args.taskId,
31+ status: "failed",
32+ error: `Runtime not found: ${task.runtimeId}`,
33+ });
34+
35+ if (failed) {
36+ await publishTaskEvent(ctx, args.taskId, "task", "task.failed", {
37+ taskId: args.taskId,
38+ status: failed.status,
39+ error: failed.error,
40+ });
41+ }
42+ return null;
43+ }
44+
45+ if (task.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID && !isCloudflareWorkerLoaderConfigured()) {
46+ const failed = await ctx.runMutation(internal.database.markTaskFinished as any, {
47+ taskId: args.taskId,
48+ status: "failed",
49+ error: `Runtime is not configured: ${task.runtimeId}`,
50+ });
51+
52+ if (failed) {
53+ await publishTaskEvent(ctx, args.taskId, "task", "task.failed", {
54+ taskId: args.taskId,
55+ status: failed.status,
56+ error: failed.error,
57+ });
58+ }
59+ return null;
60+ }
61+
62+ try {
63+ const running = (await ctx.runMutation(internal.database.markTaskRunning, {
64+ taskId: args.taskId,
65+ })) as TaskRecord | null;
66+ if (!running) {
67+ return null;
68+ }
69+
70+ await publishTaskEvent(ctx, args.taskId, "task", "task.running", {
71+ taskId: args.taskId,
72+ status: running.status,
73+ startedAt: running.startedAt,
74+ });
75+
76+ if (running.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID) {
77+ const dispatchResult = await dispatchCodeWithCloudflareWorkerLoader({
78+ taskId: args.taskId,
79+ code: running.code,
80+ timeoutMs: running.timeoutMs,
81+ });
82+
83+ if (!dispatchResult.ok) {
84+ const failed = await ctx.runMutation(internal.database.markTaskFinished as any, {
85+ taskId: args.taskId,
86+ status: "failed",
87+ error: dispatchResult.error,
88+ });
89+
90+ if (failed) {
91+ await publishTaskEvent(ctx, args.taskId, "task", "task.failed", {
92+ taskId: args.taskId,
93+ status: failed.status,
94+ error: failed.error,
95+ completedAt: failed.completedAt,
96+ });
97+ }
98+ return null;
99+ }
100+
101+ await publishTaskEvent(ctx, args.taskId, "task", "task.dispatched", {
102+ taskId: args.taskId,
103+ runtimeId: running.runtimeId,
104+ dispatchId: dispatchResult.dispatchId,
105+ durationMs: dispatchResult.durationMs,
106+ });
107+ return null;
108+ }
109+
110+ const runtimeResult = await (async () => {
111+ const adapter = new InProcessExecutionAdapter({
112+ runId: args.taskId,
113+ invokeTool: async (call) => await invokeTool(ctx, running, call),
114+ });
115+
116+ return await runCodeWithAdapter(
117+ {
118+ taskId: args.taskId,
119+ code: running.code,
120+ timeoutMs: running.timeoutMs,
121+ },
122+ adapter,
123+ );
124+ })();
125+
126+ const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {
127+ taskId: args.taskId,
128+ status: runtimeResult.status,
129+ result: runtimeResult.result,
130+ exitCode: runtimeResult.exitCode,
131+ error: runtimeResult.error,
132+ });
133+
134+ if (!finished) {
135+ return null;
136+ }
137+
138+ const terminalEvent =
139+ runtimeResult.status === "completed"
140+ ? "task.completed"
141+ : runtimeResult.status === "timed_out"
142+ ? "task.timed_out"
143+ : runtimeResult.status === "denied"
144+ ? "task.denied"
145+ : "task.failed";
146+
147+ await publishTaskEvent(ctx, args.taskId, "task", terminalEvent, {
148+ taskId: args.taskId,
149+ status: finished.status,
150+ exitCode: finished.exitCode,
151+ durationMs: runtimeResult.durationMs,
152+ error: finished.error,
153+ completedAt: finished.completedAt,
154+ });
155+ } catch (error) {
156+ const message = describeError(error);
157+ const denied = message.startsWith(APPROVAL_DENIED_PREFIX);
158+ const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {
159+ taskId: args.taskId,
160+ status: denied ? "denied" : "failed",
161+ error: denied ? message.replace(APPROVAL_DENIED_PREFIX, "") : message,
162+ });
163+
164+ if (finished) {
165+ await publishTaskEvent(ctx, args.taskId, "task", denied ? "task.denied" : "task.failed", {
166+ taskId: args.taskId,
167+ status: finished.status,
168+ error: finished.error,
169+ completedAt: finished.completedAt,
170+ });
171+ }
172+ }
173+
174+ return null;
175+}
executor/packages/convex/executor_node/tool_descriptors.ts+103−0
@@ -0,0 +1,103 @@
1+"use node";
2+
3+import type {
4+ AccessPolicyRecord,
5+ OpenApiSourceQuality,
6+ ToolDefinition,
7+ ToolDescriptor,
8+} from "../../core/src/types";
9+import { getDecisionForContext } from "./policy";
10+
11+function toToolDescriptor(tool: ToolDefinition, approval: "auto" | "required"): ToolDescriptor {
12+ return {
13+ path: tool.path,
14+ description: tool.description,
15+ approval,
16+ source: tool.source,
17+ argsType: tool.metadata?.displayArgsType ?? tool.metadata?.argsType,
18+ returnsType: tool.metadata?.displayReturnsType ?? tool.metadata?.returnsType,
19+ strictArgsType: tool.metadata?.argsType,
20+ strictReturnsType: tool.metadata?.returnsType,
21+ argPreviewKeys: tool.metadata?.argPreviewKeys,
22+ operationId: tool.metadata?.operationId,
23+ };
24+}
25+
26+export function computeOpenApiSourceQuality(
27+ workspaceTools: Map<string, ToolDefinition>,
28+): Record<string, OpenApiSourceQuality> {
29+ const grouped = new Map<string, ToolDefinition[]>();
30+
31+ for (const tool of workspaceTools.values()) {
32+ const sourceKey = tool.source;
33+ if (!sourceKey || !sourceKey.startsWith("openapi:")) continue;
34+ const list = grouped.get(sourceKey) ?? [];
35+ list.push(tool);
36+ grouped.set(sourceKey, list);
37+ }
38+
39+ const qualityBySource: Record<string, OpenApiSourceQuality> = {};
40+
41+ for (const [sourceKey, tools] of grouped.entries()) {
42+ const toolCount = tools.length;
43+
44+ let unknownArgsCount = 0;
45+ let unknownReturnsCount = 0;
46+ let partialUnknownArgsCount = 0;
47+ let partialUnknownReturnsCount = 0;
48+
49+ for (const tool of tools) {
50+ const argsType = tool.metadata?.argsType?.trim() ?? "";
51+ const returnsType = tool.metadata?.returnsType?.trim() ?? "";
52+
53+ if (!argsType || argsType === "Record<string, unknown>") {
54+ unknownArgsCount += 1;
55+ }
56+ if (!returnsType || returnsType === "unknown") {
57+ unknownReturnsCount += 1;
58+ }
59+ if (argsType.includes("unknown")) {
60+ partialUnknownArgsCount += 1;
61+ }
62+ if (returnsType.includes("unknown")) {
63+ partialUnknownReturnsCount += 1;
64+ }
65+ }
66+
67+ const argsQuality = toolCount > 0 ? (toolCount - unknownArgsCount) / toolCount : 1;
68+ const returnsQuality = toolCount > 0 ? (toolCount - unknownReturnsCount) / toolCount : 1;
69+ const overallQuality = (argsQuality + returnsQuality) / 2;
70+
71+ qualityBySource[sourceKey] = {
72+ sourceKey,
73+ toolCount,
74+ unknownArgsCount,
75+ unknownReturnsCount,
76+ partialUnknownArgsCount,
77+ partialUnknownReturnsCount,
78+ argsQuality,
79+ returnsQuality,
80+ overallQuality,
81+ };
82+ }
83+
84+ return qualityBySource;
85+}
86+
87+export function listVisibleToolDescriptors(
88+ workspaceTools: Map<string, ToolDefinition>,
89+ context: { workspaceId: string; actorId?: string; clientId?: string },
90+ policies: AccessPolicyRecord[],
91+): ToolDescriptor[] {
92+ const all = [...workspaceTools.values()];
93+
94+ return all
95+ .filter((tool) => {
96+ const decision = getDecisionForContext(tool, context, policies);
97+ return decision !== "deny";
98+ })
99+ .map((tool) => {
100+ const decision = getDecisionForContext(tool, context, policies);
101+ return toToolDescriptor(tool, decision === "require_approval" ? "required" : "auto");
102+ });
103+}
executor/packages/convex/executor_node/tool_invocation.ts+374−0
@@ -0,0 +1,374 @@
1+"use node";
2+
3+import type { ActionCtx } from "../_generated/server";
4+import { internal } from "../_generated/api";
5+import { resolveCredentialPayload } from "../../core/src/credential-providers";
6+import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../core/src/execution-constants";
7+import { parseGraphqlOperationPaths } from "../../core/src/tool-sources";
8+import type {
9+ AccessPolicyRecord,
10+ CredentialScope,
11+ PolicyDecision,
12+ ResolvedToolCredential,
13+ TaskRecord,
14+ ToolCallRecord,
15+ ToolCallRequest,
16+ ToolCredentialSpec,
17+ ToolDefinition,
18+ ToolRunContext,
19+} from "../../core/src/types";
20+import { asPayload, describeError } from "../../core/src/utils";
21+import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy";
22+import { resolveAliasedToolPath, suggestToolPaths } from "./tool_paths";
23+import { baseTools, getWorkspaceTools } from "./workspace_tools";
24+import { publishTaskEvent } from "./events";
25+
26+function createApprovalId(): string {
27+ return `approval_${crypto.randomUUID()}`;
28+}
29+
30+async function resolveCredentialHeaders(
31+ ctx: ActionCtx,
32+ spec: ToolCredentialSpec,
33+ task: TaskRecord,
34+): Promise<ResolvedToolCredential | null> {
35+ const record = await ctx.runQuery(internal.database.resolveCredential, {
36+ workspaceId: task.workspaceId,
37+ sourceKey: spec.sourceKey,
38+ scope: spec.mode as CredentialScope,
39+ actorId: task.actorId,
40+ });
41+
42+ const source = record
43+ ? await resolveCredentialPayload(record)
44+ : spec.staticSecretJson ?? null;
45+ if (!source) {
46+ return null;
47+ }
48+
49+ const headers: Record<string, string> = {};
50+ if (spec.authType === "bearer") {
51+ const token = String((source as Record<string, unknown>).token ?? "").trim();
52+ if (token) headers.authorization = `Bearer ${token}`;
53+ } else if (spec.authType === "apiKey") {
54+ const headerName = spec.headerName ?? String((source as Record<string, unknown>).headerName ?? "x-api-key");
55+ const value = String((source as Record<string, unknown>).value ?? (source as Record<string, unknown>).token ?? "").trim();
56+ if (value) headers[headerName] = value;
57+ } else if (spec.authType === "basic") {
58+ const username = String((source as Record<string, unknown>).username ?? "");
59+ const password = String((source as Record<string, unknown>).password ?? "");
60+ if (username || password) {
61+ const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
62+ headers.authorization = `Basic ${encoded}`;
63+ }
64+ }
65+
66+ if (Object.keys(headers).length === 0) {
67+ const bindingOverrides = asPayload((record?.overridesJson as unknown) ?? {});
68+ const overrideHeaders = asPayload(bindingOverrides.headers);
69+ if (Object.keys(overrideHeaders).length === 0) {
70+ return null;
71+ }
72+ for (const [key, value] of Object.entries(overrideHeaders)) {
73+ if (!key) continue;
74+ headers[key] = String(value);
75+ }
76+ } else {
77+ const bindingOverrides = asPayload((record?.overridesJson as unknown) ?? {});
78+ const overrideHeaders = asPayload(bindingOverrides.headers);
79+ for (const [key, value] of Object.entries(overrideHeaders)) {
80+ if (!key) continue;
81+ headers[key] = String(value);
82+ }
83+ }
84+
85+ return {
86+ sourceKey: spec.sourceKey,
87+ mode: spec.mode,
88+ headers,
89+ };
90+}
91+
92+function getGraphqlDecision(
93+ task: TaskRecord,
94+ tool: ToolDefinition,
95+ input: unknown,
96+ workspaceTools: Map<string, ToolDefinition>,
97+ policies: AccessPolicyRecord[],
98+): { decision: PolicyDecision; effectivePaths: string[] } {
99+ const sourceName = tool._graphqlSource!;
100+ const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
101+ const queryString = typeof payload.query === "string" ? payload.query : "";
102+
103+ if (!queryString.trim()) {
104+ return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };
105+ }
106+
107+ const { fieldPaths } = parseGraphqlOperationPaths(sourceName, queryString);
108+ if (fieldPaths.length === 0) {
109+ return { decision: getToolDecision(task, tool, policies), effectivePaths: [tool.path] };
110+ }
111+
112+ let worstDecision: PolicyDecision = "allow";
113+
114+ for (const fieldPath of fieldPaths) {
115+ const pseudoTool = workspaceTools.get(fieldPath);
116+ const fieldDecision = pseudoTool
117+ ? getDecisionForContext(
118+ pseudoTool,
119+ {
120+ workspaceId: task.workspaceId,
121+ actorId: task.actorId,
122+ clientId: task.clientId,
123+ },
124+ policies,
125+ )
126+ : getDecisionForContext(
127+ { ...tool, path: fieldPath, approval: fieldPath.includes(".mutation.") ? "required" : "auto" },
128+ {
129+ workspaceId: task.workspaceId,
130+ actorId: task.actorId,
131+ clientId: task.clientId,
132+ },
133+ policies,
134+ );
135+
136+ if (fieldDecision === "deny") {
137+ worstDecision = "deny";
138+ break;
139+ }
140+ if (fieldDecision === "require_approval") {
141+ worstDecision = "require_approval";
142+ }
143+ }
144+
145+ return { decision: worstDecision, effectivePaths: fieldPaths };
146+}
147+
148+export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {
149+ const { toolPath, input, callId } = call;
150+ const persistedCall = (await ctx.runMutation(internal.database.upsertToolCallRequested, {
151+ taskId: task.id,
152+ callId,
153+ workspaceId: task.workspaceId,
154+ toolPath,
155+ })) as ToolCallRecord;
156+
157+ if (persistedCall.status === "completed") {
158+ throw new Error(`Tool call ${callId} already completed; output is not retained`);
159+ }
160+
161+ if (persistedCall.status === "failed") {
162+ throw new Error(persistedCall.error ?? `Tool call failed: ${callId}`);
163+ }
164+
165+ if (persistedCall.status === "denied") {
166+ throw new Error(`${APPROVAL_DENIED_PREFIX}${persistedCall.error ?? persistedCall.toolPath}`);
167+ }
168+
169+ const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
170+ const typedPolicies = policies as AccessPolicyRecord[];
171+
172+ let workspaceTools: Map<string, ToolDefinition> | undefined;
173+ let resolvedToolPath = toolPath;
174+ let tool = baseTools.get(toolPath);
175+ if (!tool) {
176+ const result = await getWorkspaceTools(ctx, task.workspaceId);
177+ workspaceTools = result.tools;
178+ tool = workspaceTools.get(toolPath);
179+
180+ if (!tool) {
181+ const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools);
182+ if (aliasedPath) {
183+ resolvedToolPath = aliasedPath;
184+ tool = workspaceTools.get(aliasedPath);
185+ }
186+ }
187+ }
188+
189+ if (!tool) {
190+ const availableTools = workspaceTools ?? baseTools;
191+ const suggestions = suggestToolPaths(toolPath, availableTools);
192+ const suggestionText = suggestions.length > 0
193+ ? `\nDid you mean: ${suggestions.map((path) => `tools.${path}`).join(", ")}`
194+ : "";
195+ throw new Error(`Unknown tool: ${toolPath}${suggestionText}`);
196+ }
197+
198+ let decision: PolicyDecision;
199+ let effectiveToolPath = resolvedToolPath;
200+ if (tool._graphqlSource) {
201+ if (!workspaceTools) {
202+ const result = await getWorkspaceTools(ctx, task.workspaceId);
203+ workspaceTools = result.tools;
204+ }
205+ const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies);
206+ decision = result.decision;
207+ if (result.effectivePaths.length > 0) {
208+ effectiveToolPath = result.effectivePaths.join(", ");
209+ }
210+ } else {
211+ decision = getToolDecision(task, tool, typedPolicies);
212+ }
213+
214+ const publishToolStarted = persistedCall.status === "requested";
215+
216+ if (decision === "deny") {
217+ const deniedMessage = `${effectiveToolPath} (policy denied)`;
218+ await ctx.runMutation(internal.database.finishToolCall, {
219+ taskId: task.id,
220+ callId,
221+ status: "denied",
222+ error: deniedMessage,
223+ });
224+ await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", {
225+ taskId: task.id,
226+ callId,
227+ toolPath: effectiveToolPath,
228+ reason: "policy_deny",
229+ });
230+ throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
231+ }
232+
233+ let credential: ResolvedToolCredential | undefined;
234+ if (tool.credential) {
235+ const resolved = await resolveCredentialHeaders(ctx, tool.credential, task);
236+ if (!resolved) {
237+ throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`);
238+ }
239+ credential = resolved;
240+ }
241+
242+ if (publishToolStarted) {
243+ await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {
244+ taskId: task.id,
245+ callId,
246+ toolPath: effectiveToolPath,
247+ approval: decision === "require_approval" ? "required" : "auto",
248+ });
249+ }
250+
251+ let approvalSatisfied = false;
252+ if (persistedCall.approvalId) {
253+ const existingApproval = await ctx.runQuery(internal.database.getApproval, {
254+ approvalId: persistedCall.approvalId,
255+ });
256+ if (!existingApproval) {
257+ throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`);
258+ }
259+
260+ if (existingApproval.status === "pending") {
261+ throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`);
262+ }
263+
264+ if (existingApproval.status === "denied") {
265+ const deniedMessage = `${effectiveToolPath} (${existingApproval.id})`;
266+ await ctx.runMutation(internal.database.finishToolCall, {
267+ taskId: task.id,
268+ callId,
269+ status: "denied",
270+ error: deniedMessage,
271+ });
272+ await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", {
273+ taskId: task.id,
274+ callId,
275+ toolPath: effectiveToolPath,
276+ approvalId: existingApproval.id,
277+ });
278+ throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
279+ }
280+
281+ approvalSatisfied = existingApproval.status === "approved";
282+ }
283+
284+ if (decision === "require_approval" && !approvalSatisfied) {
285+ const approvalId = persistedCall.approvalId ?? createApprovalId();
286+ let approval = await ctx.runQuery(internal.database.getApproval, {
287+ approvalId,
288+ });
289+
290+ if (!approval) {
291+ approval = await ctx.runMutation(internal.database.createApproval, {
292+ id: approvalId,
293+ taskId: task.id,
294+ toolPath: effectiveToolPath,
295+ input,
296+ });
297+
298+ await publishTaskEvent(ctx, task.id, "approval", "approval.requested", {
299+ approvalId: approval.id,
300+ taskId: task.id,
301+ callId,
302+ toolPath: approval.toolPath,
303+ input: asPayload(approval.input),
304+ createdAt: approval.createdAt,
305+ });
306+ }
307+
308+ await ctx.runMutation(internal.database.setToolCallPendingApproval, {
309+ taskId: task.id,
310+ callId,
311+ approvalId: approval.id,
312+ });
313+
314+ if (approval.status === "pending") {
315+ throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`);
316+ }
317+
318+ if (approval.status === "denied") {
319+ const deniedMessage = `${effectiveToolPath} (${approval.id})`;
320+ await ctx.runMutation(internal.database.finishToolCall, {
321+ taskId: task.id,
322+ callId,
323+ status: "denied",
324+ error: deniedMessage,
325+ });
326+ await publishTaskEvent(ctx, task.id, "task", "tool.call.denied", {
327+ taskId: task.id,
328+ callId,
329+ toolPath: effectiveToolPath,
330+ approvalId: approval.id,
331+ });
332+ throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`);
333+ }
334+ }
335+
336+ try {
337+ const context: ToolRunContext = {
338+ taskId: task.id,
339+ workspaceId: task.workspaceId,
340+ actorId: task.actorId,
341+ clientId: task.clientId,
342+ credential,
343+ isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies),
344+ };
345+ const value = await tool.run(input, context);
346+ await ctx.runMutation(internal.database.finishToolCall, {
347+ taskId: task.id,
348+ callId,
349+ status: "completed",
350+ });
351+ await publishTaskEvent(ctx, task.id, "task", "tool.call.completed", {
352+ taskId: task.id,
353+ callId,
354+ toolPath: effectiveToolPath,
355+ outputRedacted: true,
356+ });
357+ return value;
358+ } catch (error) {
359+ const message = describeError(error);
360+ await ctx.runMutation(internal.database.finishToolCall, {
361+ taskId: task.id,
362+ callId,
363+ status: "failed",
364+ error: message,
365+ });
366+ await publishTaskEvent(ctx, task.id, "task", "tool.call.failed", {
367+ taskId: task.id,
368+ callId,
369+ toolPath: effectiveToolPath,
370+ error: message,
371+ });
372+ throw error;
373+ }
374+}
executor/packages/convex/executor_node/tool_paths.ts+105−0
@@ -0,0 +1,105 @@
1+"use node";
2+
3+import type { ToolDefinition } from "../../core/src/types";
4+
5+function normalizeToolPathSegment(segment: string): string {
6+ return segment.toLowerCase().replace(/[^a-z0-9]/g, "");
7+}
8+
9+function normalizeToolPath(path: string): string {
10+ return path
11+ .split(".")
12+ .filter(Boolean)
13+ .map((segment) => normalizeToolPathSegment(segment))
14+ .join(".");
15+}
16+
17+function levenshteinDistance(a: string, b: string): number {
18+ if (a === b) return 0;
19+ if (a.length === 0) return b.length;
20+ if (b.length === 0) return a.length;
21+
22+ const prev = new Array<number>(b.length + 1);
23+ const curr = new Array<number>(b.length + 1);
24+ for (let j = 0; j <= b.length; j++) prev[j] = j;
25+
26+ for (let i = 1; i <= a.length; i++) {
27+ curr[0] = i;
28+ for (let j = 1; j <= b.length; j++) {
29+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
30+ curr[j] = Math.min(
31+ curr[j - 1] + 1,
32+ prev[j] + 1,
33+ prev[j - 1] + cost,
34+ );
35+ }
36+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
37+ }
38+
39+ return prev[b.length] ?? Math.max(a.length, b.length);
40+}
41+
42+export function resolveAliasedToolPath(
43+ requestedPath: string,
44+ toolMap: Map<string, ToolDefinition>,
45+): string | null {
46+ if (toolMap.has(requestedPath)) return requestedPath;
47+
48+ const normalizedRequested = normalizeToolPath(requestedPath);
49+ if (!normalizedRequested) return null;
50+
51+ const matches: string[] = [];
52+ for (const path of toolMap.keys()) {
53+ if (normalizeToolPath(path) === normalizedRequested) {
54+ matches.push(path);
55+ }
56+ }
57+
58+ if (matches.length === 0) return null;
59+ if (matches.length === 1) return matches[0]!;
60+
61+ const requestedSegments = requestedPath.split(".").length;
62+ const sameSegmentCount = matches.filter((path) => path.split(".").length === requestedSegments);
63+ const pool = sameSegmentCount.length > 0 ? sameSegmentCount : matches;
64+ return [...pool].sort((a, b) => a.length - b.length || a.localeCompare(b))[0] ?? null;
65+}
66+
67+export function suggestToolPaths(
68+ requestedPath: string,
69+ toolMap: Map<string, ToolDefinition>,
70+ limit = 3,
71+): string[] {
72+ const normalizedRequested = normalizeToolPath(requestedPath);
73+ const requestedSegments = normalizedRequested.split(".").filter(Boolean);
74+ const requestedNamespace = requestedSegments[0] ?? "";
75+
76+ return [...toolMap.keys()]
77+ .map((path) => {
78+ const normalizedCandidate = normalizeToolPath(path);
79+ const candidateSegments = normalizedCandidate.split(".").filter(Boolean);
80+ const candidateNamespace = candidateSegments[0] ?? "";
81+
82+ let score = -levenshteinDistance(normalizedRequested, normalizedCandidate);
83+
84+ if (requestedNamespace && requestedNamespace === candidateNamespace) {
85+ score += 6;
86+ }
87+
88+ if (normalizedCandidate.includes(normalizedRequested) || normalizedRequested.includes(normalizedCandidate)) {
89+ score += 3;
90+ }
91+
92+ const sharedPrefix = Math.min(requestedSegments.length, candidateSegments.length);
93+ let prefixMatches = 0;
94+ for (let i = 0; i < sharedPrefix; i++) {
95+ if (requestedSegments[i] !== candidateSegments[i]) break;
96+ prefixMatches += 1;
97+ }
98+ score += prefixMatches * 2;
99+
100+ return { path, score };
101+ })
102+ .sort((a, b) => b.score - a.score)
103+ .slice(0, limit)
104+ .map((item) => item.path);
105+}
executor/packages/convex/executor_node/tool_source_loading.ts+194−0
@@ -0,0 +1,194 @@
1+"use node";
2+
3+import type { ActionCtx } from "../_generated/server";
4+import { internal } from "../_generated/api";
5+import {
6+ compileExternalToolSource,
7+ compileOpenApiToolSourceFromPrepared,
8+ prepareOpenApiSpec,
9+ type CompiledToolSourceArtifact,
10+ type ExternalToolSourceConfig,
11+ type GraphqlToolSourceConfig,
12+ type McpToolSourceConfig,
13+ type OpenApiToolSourceConfig,
14+ type PreparedOpenApiSpec,
15+} from "../../core/src/tool-sources";
16+import type { ToolSourceRecord } from "../../core/src/types";
17+
18+const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
19+
20+/** Cache version - bump when PreparedOpenApiSpec shape changes. */
21+const OPENAPI_CACHE_VERSION = "v15";
22+
23+export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
24+ const signatureVersion = "v15";
25+ const parts = sources
26+ .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
27+ .sort();
28+ return `${signatureVersion}|${workspaceId}|${parts.join(",")}`;
29+}
30+
31+export function normalizeExternalToolSource(raw: {
32+ id: string;
33+ type: ToolSourceRecord["type"];
34+ name: string;
35+ config: Record<string, unknown>;
36+}): ExternalToolSourceConfig {
37+ const config = raw.config;
38+
39+ if (raw.type === "mcp") {
40+ if (typeof config.url !== "string" || config.url.trim().length === 0) {
41+ throw new Error(`MCP source '${raw.name}' missing url`);
42+ }
43+
44+ if (
45+ config.transport !== undefined
46+ && config.transport !== "sse"
47+ && config.transport !== "streamable-http"
48+ ) {
49+ throw new Error(`MCP source '${raw.name}' has invalid transport`);
50+ }
51+
52+ if (config.queryParams !== undefined) {
53+ const queryParams = config.queryParams;
54+ if (!queryParams || typeof queryParams !== "object" || Array.isArray(queryParams)) {
55+ throw new Error(`MCP source '${raw.name}' queryParams must be an object`);
56+ }
57+
58+ for (const value of Object.values(queryParams as Record<string, unknown>)) {
59+ if (typeof value !== "string") {
60+ throw new Error(`MCP source '${raw.name}' queryParams values must be strings`);
61+ }
62+ }
63+ }
64+
65+ const result: McpToolSourceConfig = {
66+ type: "mcp",
67+ name: raw.name,
68+ sourceId: raw.id,
69+ sourceKey: `source:${raw.id}`,
70+ url: config.url,
71+ transport: config.transport as McpToolSourceConfig["transport"],
72+ queryParams: config.queryParams as McpToolSourceConfig["queryParams"],
73+ defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"],
74+ overrides: config.overrides as McpToolSourceConfig["overrides"],
75+ };
76+ return result;
77+ }
78+
79+ if (raw.type === "graphql") {
80+ if (typeof config.endpoint !== "string" || config.endpoint.trim().length === 0) {
81+ throw new Error(`GraphQL source '${raw.name}' missing endpoint`);
82+ }
83+
84+ const result: GraphqlToolSourceConfig = {
85+ type: "graphql",
86+ name: raw.name,
87+ sourceId: raw.id,
88+ sourceKey: `source:${raw.id}`,
89+ endpoint: config.endpoint,
90+ schema: config.schema as GraphqlToolSourceConfig["schema"],
91+ auth: config.auth as GraphqlToolSourceConfig["auth"],
92+ defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"],
93+ defaultMutationApproval: config.defaultMutationApproval as GraphqlToolSourceConfig["defaultMutationApproval"],
94+ overrides: config.overrides as GraphqlToolSourceConfig["overrides"],
95+ };
96+ return result;
97+ }
98+
99+ const spec = config.spec;
100+ if (typeof spec !== "string" && (typeof spec !== "object" || spec === null)) {
101+ throw new Error(`OpenAPI source '${raw.name}' missing spec`);
102+ }
103+
104+ const result: OpenApiToolSourceConfig = {
105+ type: "openapi",
106+ name: raw.name,
107+ sourceId: raw.id,
108+ sourceKey: `source:${raw.id}`,
109+ spec: spec as OpenApiToolSourceConfig["spec"],
110+ baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"],
111+ auth: config.auth as OpenApiToolSourceConfig["auth"],
112+ defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"],
113+ defaultWriteApproval: config.defaultWriteApproval as OpenApiToolSourceConfig["defaultWriteApproval"],
114+ overrides: config.overrides as OpenApiToolSourceConfig["overrides"],
115+ };
116+ return result;
117+}
118+
119+async function loadCachedOpenApiSpec(
120+ ctx: ActionCtx,
121+ specUrl: string,
122+ sourceName: string,
123+): Promise<PreparedOpenApiSpec> {
124+ try {
125+ const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, {
126+ specUrl,
127+ version: OPENAPI_CACHE_VERSION,
128+ maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS,
129+ });
130+
131+ if (entry) {
132+ const blob = await ctx.storage.get(entry.storageId);
133+ if (blob) {
134+ const json = await blob.text();
135+ return JSON.parse(json) as PreparedOpenApiSpec;
136+ }
137+ }
138+ } catch (error) {
139+ const message = error instanceof Error ? error.message : String(error);
140+ console.warn(`[executor] OpenAPI cache read failed for '${sourceName}': ${message}`);
141+ }
142+
143+ const prepared = await prepareOpenApiSpec(specUrl, sourceName);
144+
145+ try {
146+ const json = JSON.stringify(prepared);
147+ const blob = new Blob([json], { type: "application/json" });
148+ const storageId = await ctx.storage.store(blob);
149+ await ctx.runMutation(internal.openApiSpecCache.putEntry, {
150+ specUrl,
151+ version: OPENAPI_CACHE_VERSION,
152+ storageId,
153+ sizeBytes: json.length,
154+ });
155+ } catch (error) {
156+ const message = error instanceof Error ? error.message : String(error);
157+ console.warn(`[executor] OpenAPI cache write failed for '${sourceName}': ${message}`);
158+ }
159+
160+ return prepared;
161+}
162+
163+export async function loadSourceArtifact(
164+ ctx: ActionCtx,
165+ source: ExternalToolSourceConfig,
166+): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> {
167+ if (source.type === "openapi" && typeof source.spec === "string") {
168+ try {
169+ const prepared = await loadCachedOpenApiSpec(ctx, source.spec, source.name);
170+ const artifact = compileOpenApiToolSourceFromPrepared(source, prepared);
171+ const warnings = (prepared.warnings ?? []).map(
172+ (warning) => `Source '${source.name}': ${warning}`,
173+ );
174+ return { artifact, warnings };
175+ } catch (error) {
176+ const message = error instanceof Error ? error.message : String(error);
177+ return {
178+ artifact: undefined,
179+ warnings: [`Failed to load openapi source '${source.name}': ${message}`],
180+ };
181+ }
182+ }
183+
184+ try {
185+ const artifact = await compileExternalToolSource(source);
186+ return { artifact, warnings: [] };
187+ } catch (error) {
188+ const message = error instanceof Error ? error.message : String(error);
189+ return {
190+ artifact: undefined,
191+ warnings: [`Failed to load ${source.type} source '${source.name}': ${message}`],
192+ };
193+ }
194+}
executor/packages/convex/executor_node/workspace_tools.ts+304−0
@@ -0,0 +1,304 @@
1+"use node";
2+
3+import type { ActionCtx } from "../_generated/server";
4+import { internal } from "../_generated/api";
5+import type { Id } from "../_generated/dataModel";
6+import { createDiscoverTool } from "../../core/src/tool-discovery";
7+import {
8+ materializeCompiledToolSource,
9+ materializeWorkspaceSnapshot,
10+ type CompiledToolSourceArtifact,
11+ type ExternalToolSourceConfig,
12+ type WorkspaceToolSnapshot,
13+} from "../../core/src/tool-sources";
14+import { DEFAULT_TOOLS } from "../../core/src/tools";
15+import type {
16+ AccessPolicyRecord,
17+ OpenApiSourceQuality,
18+ SourceAuthProfile,
19+ ToolDefinition,
20+ ToolDescriptor,
21+} from "../../core/src/types";
22+import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";
23+import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
24+
25+const baseTools = new Map<string, ToolDefinition>(DEFAULT_TOOLS.map((tool) => [tool.path, tool]));
26+
27+export interface DtsStorageEntry {
28+ sourceKey: string;
29+ storageId: Id<"_storage">;
30+}
31+
32+export interface WorkspaceToolsResult {
33+ tools: Map<string, ToolDefinition>;
34+ warnings: string[];
35+ dtsStorageIds: DtsStorageEntry[];
36+}
37+
38+export interface WorkspaceToolInventory {
39+ tools: ToolDescriptor[];
40+ warnings: string[];
41+ dtsStorageIds: DtsStorageEntry[];
42+ sourceQuality: Record<string, OpenApiSourceQuality>;
43+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
44+}
45+
46+function computeSourceAuthProfiles(tools: Map<string, ToolDefinition>): Record<string, SourceAuthProfile> {
47+ const profiles: Record<string, SourceAuthProfile> = {};
48+
49+ for (const tool of tools.values()) {
50+ const credential = tool.credential;
51+ if (!credential) continue;
52+
53+ const sourceKey = credential.sourceKey;
54+ const current = profiles[sourceKey];
55+ if (!current) {
56+ profiles[sourceKey] = {
57+ type: credential.authType,
58+ mode: credential.mode,
59+ ...(credential.authType === "apiKey" && credential.headerName
60+ ? { header: credential.headerName }
61+ : {}),
62+ inferred: true,
63+ };
64+ continue;
65+ }
66+
67+ if (current.type !== credential.authType || current.mode !== credential.mode) {
68+ profiles[sourceKey] = {
69+ type: "mixed",
70+ inferred: true,
71+ };
72+ }
73+ }
74+
75+ return profiles;
76+}
77+
78+export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspaces">): Promise<WorkspaceToolsResult> {
79+ const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))
80+ .filter((source: { enabled: boolean }) => source.enabled);
81+ const signature = sourceSignature(workspaceId, sources);
82+
83+ try {
84+ const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, {
85+ workspaceId,
86+ signature,
87+ });
88+
89+ if (cacheEntry) {
90+ const blob = await ctx.storage.get(cacheEntry.storageId);
91+ if (blob) {
92+ const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;
93+ const restored = materializeWorkspaceSnapshot(snapshot);
94+
95+ const merged = new Map<string, ToolDefinition>();
96+ for (const tool of baseTools.values()) {
97+ if (tool.path === "discover") continue;
98+ merged.set(tool.path, tool);
99+ }
100+ for (const tool of restored) {
101+ if (tool.path === "discover") continue;
102+ merged.set(tool.path, tool);
103+ }
104+ const discover = createDiscoverTool([...merged.values()]);
105+ merged.set(discover.path, discover);
106+
107+ const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[];
108+
109+ return { tools: merged, warnings: snapshot.warnings, dtsStorageIds };
110+ }
111+ }
112+ } catch (error) {
113+ const msg = error instanceof Error ? error.message : String(error);
114+ console.warn(`[executor] workspace tool cache read failed for '${workspaceId}': ${msg}`);
115+ }
116+
117+ const configs: ExternalToolSourceConfig[] = [];
118+ const warnings: string[] = [];
119+ for (const source of sources) {
120+ try {
121+ configs.push(normalizeExternalToolSource(source));
122+ } catch (error) {
123+ const message = error instanceof Error ? error.message : String(error);
124+ warnings.push(`Source '${source.name}': ${message}`);
125+ }
126+ }
127+
128+ const loadedSources = await Promise.all(configs.map((config) => loadSourceArtifact(ctx, config)));
129+ const externalArtifacts = loadedSources
130+ .map((loaded) => loaded.artifact)
131+ .filter((artifact): artifact is CompiledToolSourceArtifact => Boolean(artifact));
132+ const externalTools = externalArtifacts.flatMap((artifact) => materializeCompiledToolSource(artifact));
133+ warnings.push(...loadedSources.flatMap((loaded) => loaded.warnings));
134+
135+ const merged = new Map<string, ToolDefinition>();
136+ for (const tool of baseTools.values()) {
137+ if (tool.path === "discover") continue;
138+ merged.set(tool.path, tool);
139+ }
140+ for (const tool of externalTools) {
141+ merged.set(tool.path, tool);
142+ }
143+
144+ const discover = createDiscoverTool([...merged.values()]);
145+ merged.set(discover.path, discover);
146+
147+ let dtsStorageIds: DtsStorageEntry[] = [];
148+ try {
149+ const allTools = [...merged.values()];
150+
151+ const seenDtsSources = new Set<string>();
152+ const dtsEntries: { sourceKey: string; content: string }[] = [];
153+ for (const artifact of externalArtifacts) {
154+ for (const tool of artifact.tools) {
155+ if (tool.metadata?.sourceDts && tool.source && !seenDtsSources.has(tool.source)) {
156+ seenDtsSources.add(tool.source);
157+ dtsEntries.push({ sourceKey: tool.source, content: tool.metadata.sourceDts });
158+ }
159+ }
160+ }
161+
162+ const storedDts = await Promise.all(
163+ dtsEntries.map(async (entry) => {
164+ const dtsBlob = new Blob([entry.content], { type: "text/plain" });
165+ const sid = await ctx.storage.store(dtsBlob);
166+ return { sourceKey: entry.sourceKey, storageId: sid };
167+ }),
168+ );
169+ dtsStorageIds = storedDts;
170+
171+ const sanitizedArtifacts: CompiledToolSourceArtifact[] = externalArtifacts.map((artifact) => ({
172+ ...artifact,
173+ tools: artifact.tools.map((tool) => {
174+ if (!tool.metadata?.sourceDts) return tool;
175+ const metadata = { ...tool.metadata };
176+ delete (metadata as Record<string, unknown>).sourceDts;
177+ return { ...tool, metadata };
178+ }),
179+ }));
180+
181+ const snapshot: WorkspaceToolSnapshot = {
182+ version: "v2",
183+ externalArtifacts: sanitizedArtifacts,
184+ warnings,
185+ };
186+
187+ const json = JSON.stringify(snapshot);
188+ const blob = new Blob([json], { type: "application/json" });
189+ const storageId = await ctx.storage.store(blob);
190+ await ctx.runMutation(internal.workspaceToolCache.putEntry, {
191+ workspaceId,
192+ signature,
193+ storageId,
194+ dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })),
195+ toolCount: allTools.length,
196+ sizeBytes: json.length,
197+ });
198+ } catch (error) {
199+ const msg = error instanceof Error ? error.message : String(error);
200+ console.warn(`[executor] workspace tool cache write failed for '${workspaceId}': ${msg}`);
201+ }
202+
203+ return { tools: merged, warnings, dtsStorageIds };
204+}
205+
206+export async function loadWorkspaceToolInventoryForContext(
207+ ctx: ActionCtx,
208+ context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
209+): Promise<WorkspaceToolInventory> {
210+ const [result, policies] = await Promise.all([
211+ getWorkspaceTools(ctx, context.workspaceId),
212+ ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: context.workspaceId }),
213+ ]);
214+ const typedPolicies = policies as AccessPolicyRecord[];
215+ const tools = listVisibleToolDescriptors(result.tools, context, typedPolicies);
216+ const sourceQuality = computeOpenApiSourceQuality(result.tools);
217+ const sourceAuthProfiles = computeSourceAuthProfiles(result.tools);
218+
219+ return {
220+ tools,
221+ warnings: result.warnings,
222+ dtsStorageIds: result.dtsStorageIds,
223+ sourceQuality,
224+ sourceAuthProfiles,
225+ };
226+}
227+
228+export async function listToolsForContext(
229+ ctx: ActionCtx,
230+ context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
231+): Promise<ToolDescriptor[]> {
232+ const inventory = await loadWorkspaceToolInventoryForContext(ctx, context);
233+ return inventory.tools;
234+}
235+
236+export async function listToolsWithWarningsForContext(
237+ ctx: ActionCtx,
238+ context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
239+): Promise<{
240+ tools: ToolDescriptor[];
241+ warnings: string[];
242+ dtsUrls: Record<string, string>;
243+ sourceQuality: Record<string, OpenApiSourceQuality>;
244+ sourceAuthProfiles: Record<string, SourceAuthProfile>;
245+}> {
246+ const inventory = await loadWorkspaceToolInventoryForContext(ctx, context);
247+ const dtsUrls = await loadDtsUrls(ctx, inventory.dtsStorageIds);
248+
249+ return {
250+ tools: inventory.tools,
251+ warnings: inventory.warnings,
252+ dtsUrls,
253+ sourceQuality: inventory.sourceQuality,
254+ sourceAuthProfiles: inventory.sourceAuthProfiles,
255+ };
256+}
257+
258+export async function loadDtsUrls(ctx: ActionCtx, entries: DtsStorageEntry[]): Promise<Record<string, string>> {
259+ if (entries.length === 0) return {};
260+
261+ const urlEntries = await Promise.all(entries.map(async (entry) => {
262+ try {
263+ const url = await ctx.storage.getUrl(entry.storageId);
264+ return url ? [entry.sourceKey, url] as const : null;
265+ } catch {
266+ return null;
267+ }
268+ }));
269+
270+ const dtsUrls: Record<string, string> = {};
271+ for (const pair of urlEntries) {
272+ if (!pair) continue;
273+ const [sourceKey, url] = pair;
274+ dtsUrls[sourceKey] = url;
275+ }
276+
277+ return dtsUrls;
278+}
279+
280+export async function loadWorkspaceDtsStorageIds(
281+ ctx: ActionCtx,
282+ workspaceId: Id<"workspaces">,
283+): Promise<DtsStorageEntry[]> {
284+ const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))
285+ .filter((source: { enabled: boolean }) => source.enabled);
286+ const signature = sourceSignature(workspaceId, sources);
287+
288+ try {
289+ const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, {
290+ workspaceId,
291+ signature,
292+ });
293+ if (cacheEntry) {
294+ return (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[];
295+ }
296+ } catch {
297+ // Fall through to a full rebuild path.
298+ }
299+
300+ const rebuilt = await getWorkspaceTools(ctx, workspaceId);
301+ return rebuilt.dtsStorageIds;
302+}
303+
304+export { baseTools };
executor/packages/convex/http-anonymous-oauth.e2e.test.ts+199−0
@@ -0,0 +1,199 @@
1+import { expect, test } from "bun:test";
2+import { convexTest } from "convex-test";
3+import { internal } from "./_generated/api";
4+import schema from "./schema";
5+import { computeS256Challenge } from "../core/src/anonymous-oauth";
6+
7+function setup() {
8+ return convexTest(schema, {
9+ "./database.ts": () => import("./database"),
10+ "./executor.ts": () => import("./executor"),
11+ "./executorNode.ts": () => import("./executorNode"),
12+ "./http.ts": () => import("./http"),
13+ "./auth.ts": () => import("./auth"),
14+ "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
15+ "./workspaceToolCache.ts": () => import("./workspaceToolCache"),
16+ "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
17+ "./mcpNode.ts": () => import("./mcpNode"),
18+ "./anonymousOauth.ts": () => import("./anonymousOauth"),
19+ "./_generated/api.js": () => import("./_generated/api.js"),
20+ });
21+}
22+
23+function generateCodeVerifier(): string {
24+ const bytes = new Uint8Array(32);
25+ crypto.getRandomValues(bytes);
26+ let binary = "";
27+ for (const b of bytes) {
28+ binary += String.fromCharCode(b);
29+ }
30+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
31+}
32+
33+async function fetchViaConvexTest(
34+ t: ReturnType<typeof setup>,
35+ url: string,
36+ init?: RequestInit,
37+) {
38+ const parsed = new URL(url);
39+ return await t.fetch(`${parsed.pathname}${parsed.search}${parsed.hash}`, init);
40+}
41+
42+test("anonymous OAuth flow binds token to workspace/session for MCP", async () => {
43+ const previousAnonymousOauth = process.env.MCP_ENABLE_ANONYMOUS_OAUTH;
44+ const previousAuthorizationServer = process.env.MCP_AUTHORIZATION_SERVER;
45+ process.env.MCP_ENABLE_ANONYMOUS_OAUTH = "1";
46+ delete process.env.MCP_AUTHORIZATION_SERVER;
47+
48+ const t = setup();
49+
50+ try {
51+ const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
52+ const query = new URLSearchParams({
53+ workspaceId: session.workspaceId,
54+ sessionId: session.sessionId,
55+ }).toString();
56+
57+ const protectedResourceRes = await t.fetch(`/.well-known/oauth-protected-resource?${query}`);
58+ expect(protectedResourceRes.status).toBe(200);
59+ const protectedResource = await protectedResourceRes.json() as {
60+ resource: string;
61+ authorization_servers: string[];
62+ };
63+ const protectedResourceUrl = new URL(protectedResource.resource);
64+ expect(protectedResourceUrl.pathname).toBe("/mcp");
65+ expect(protectedResourceUrl.searchParams.get("workspaceId")).toBe(session.workspaceId);
66+ expect(protectedResourceUrl.searchParams.get("sessionId")).toBe(session.sessionId);
67+ expect(protectedResource.authorization_servers.length).toBe(1);
68+
69+ const authorizationServerUrl = protectedResource.authorization_servers[0]!;
70+ const metadataWithoutQueryRes = await fetchViaConvexTest(
71+ t,
72+ `${authorizationServerUrl}/.well-known/oauth-authorization-server`,
73+ );
74+ expect(metadataWithoutQueryRes.status).toBe(200);
75+ const metadataWithoutQuery = await metadataWithoutQueryRes.json() as {
76+ issuer: string;
77+ };
78+ expect(metadataWithoutQuery.issuer).toBe(authorizationServerUrl);
79+
80+ const metadataRes = await fetchViaConvexTest(
81+ t,
82+ `${authorizationServerUrl}/.well-known/oauth-authorization-server?${query}`,
83+ );
84+ expect(metadataRes.status).toBe(200);
85+ const metadata = await metadataRes.json() as {
86+ registration_endpoint: string;
87+ authorization_endpoint: string;
88+ token_endpoint: string;
89+ };
90+
91+ const registerRes = await fetchViaConvexTest(t, metadata.registration_endpoint, {
92+ method: "POST",
93+ headers: { "content-type": "application/json" },
94+ body: JSON.stringify({
95+ redirect_uris: ["http://localhost:9999/callback"],
96+ }),
97+ });
98+ expect(registerRes.status).toBe(201);
99+ const registration = await registerRes.json() as { client_id: string };
100+
101+ const codeVerifier = generateCodeVerifier();
102+ const codeChallenge = await computeS256Challenge(codeVerifier);
103+ const authorizeUrl = new URL(metadata.authorization_endpoint);
104+ authorizeUrl.searchParams.set("response_type", "code");
105+ authorizeUrl.searchParams.set("client_id", registration.client_id);
106+ authorizeUrl.searchParams.set("redirect_uri", "http://localhost:9999/callback");
107+ authorizeUrl.searchParams.set("code_challenge", codeChallenge);
108+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
109+ authorizeUrl.searchParams.set("resource", protectedResource.resource);
110+
111+ const authorizeRes = await fetchViaConvexTest(t, authorizeUrl.toString(), { redirect: "manual" });
112+ expect(authorizeRes.status).toBe(302);
113+ const redirectLocation = authorizeRes.headers.get("location");
114+ expect(redirectLocation).toBeTruthy();
115+ const code = new URL(redirectLocation!).searchParams.get("code");
116+ expect(code).toBeTruthy();
117+
118+ const tokenRes = await fetchViaConvexTest(t, metadata.token_endpoint, {
119+ method: "POST",
120+ headers: { "content-type": "application/x-www-form-urlencoded" },
121+ body: new URLSearchParams({
122+ grant_type: "authorization_code",
123+ code: code!,
124+ client_id: registration.client_id,
125+ redirect_uri: "http://localhost:9999/callback",
126+ code_verifier: codeVerifier,
127+ }).toString(),
128+ });
129+ expect(tokenRes.status).toBe(200);
130+ const tokenBody = await tokenRes.json() as { access_token: string };
131+ expect(tokenBody.access_token).toBeTruthy();
132+
133+ const authorizedMcpRes = await t.fetch(`/mcp?${query}`, {
134+ method: "POST",
135+ headers: {
136+ authorization: `Bearer ${tokenBody.access_token}`,
137+ "content-type": "application/json",
138+ },
139+ body: JSON.stringify({}),
140+ });
141+ expect(authorizedMcpRes.status).not.toBe(401);
142+ expect(authorizedMcpRes.status).not.toBe(403);
143+
144+ const mismatchedQuery = new URLSearchParams({
145+ workspaceId: session.workspaceId,
146+ sessionId: `mcp_${crypto.randomUUID()}`,
147+ }).toString();
148+ const mismatchedMcpRes = await t.fetch(`/mcp?${mismatchedQuery}`, {
149+ method: "POST",
150+ headers: {
151+ authorization: `Bearer ${tokenBody.access_token}`,
152+ "content-type": "application/json",
153+ },
154+ body: JSON.stringify({}),
155+ });
156+ expect(mismatchedMcpRes.status).toBe(401);
157+ } finally {
158+ if (previousAnonymousOauth === undefined) {
159+ delete process.env.MCP_ENABLE_ANONYMOUS_OAUTH;
160+ } else {
161+ process.env.MCP_ENABLE_ANONYMOUS_OAUTH = previousAnonymousOauth;
162+ }
163+
164+ if (previousAuthorizationServer === undefined) {
165+ delete process.env.MCP_AUTHORIZATION_SERVER;
166+ } else {
167+ process.env.MCP_AUTHORIZATION_SERVER = previousAuthorizationServer;
168+ }
169+ }
170+});
171+
172+test("queryless protected-resource discovery prefers anonymous auth metadata", async () => {
173+ const previousAnonymousOauth = process.env.MCP_ENABLE_ANONYMOUS_OAUTH;
174+ const previousAuthorizationServer = process.env.MCP_AUTHORIZATION_SERVER;
175+ process.env.MCP_ENABLE_ANONYMOUS_OAUTH = "1";
176+ process.env.MCP_AUTHORIZATION_SERVER = "https://victorious-point-35-staging.authkit.app";
177+
178+ const t = setup();
179+ try {
180+ const res = await t.fetch("/.well-known/oauth-protected-resource");
181+ expect(res.status).toBe(200);
182+ const body = await res.json() as {
183+ authorization_servers: string[];
184+ };
185+ expect(body.authorization_servers[0]).toBe("https://some.convex.site");
186+ } finally {
187+ if (previousAnonymousOauth === undefined) {
188+ delete process.env.MCP_ENABLE_ANONYMOUS_OAUTH;
189+ } else {
190+ process.env.MCP_ENABLE_ANONYMOUS_OAUTH = previousAnonymousOauth;
191+ }
192+
193+ if (previousAuthorizationServer === undefined) {
194+ delete process.env.MCP_AUTHORIZATION_SERVER;
195+ } else {
196+ process.env.MCP_AUTHORIZATION_SERVER = previousAuthorizationServer;
197+ }
198+ }
199+});
executor/packages/convex/http.ts+16−287
@@ -1,295 +1,19 @@
11 import { registerRoutes as registerStripeRoutes } from "@convex-dev/stripe";
2−import { createRemoteJWKSet, jwtVerify } from "jose";
32 import { httpRouter } from "convex/server";
4−import { components, internal } from "./_generated/api";
5−import { httpAction } from "./_generated/server";
3+import { components } from "./_generated/api";
64 import { authKit } from "./auth";
7−import { handleMcpRequest, type McpWorkspaceContext } from "../core/src/mcp-server";
8−import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescriptor } from "../core/src/types";
9−import type { Id } from "./_generated/dataModel";
5+import { mcpHandler } from "./http/mcp_handler";
6+import {
7+ oauthAuthorizationServerHandler,
8+ oauthAuthorizeHandler,
9+ oauthJwksHandler,
10+ oauthProtectedResourceHandler,
11+ oauthRegisterHandler,
12+ oauthTokenHandler,
13+} from "./http/oauth_handlers";
1014
1115 const http = httpRouter();
1216
13−function getMcpAuthorizationServer(): string | null {
14− return process.env.MCP_AUTHORIZATION_SERVER
15− ?? process.env.MCP_AUTHORIZATION_SERVER_URL
16− ?? process.env.WORKOS_AUTHKIT_ISSUER
17− ?? process.env.WORKOS_AUTHKIT_DOMAIN
18− ?? null;
19−}
20−
21−function getMcpAuthConfig(): {
22− enabled: boolean;
23− authorizationServer: string | null;
24− jwks: ReturnType<typeof createRemoteJWKSet> | null;
25−} {
26− const authorizationServer = getMcpAuthorizationServer();
27− if (!authorizationServer) {
28− return {
29− enabled: false,
30− authorizationServer: null,
31− jwks: null,
32− };
33− }
34−
35− const jwks = createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer));
36− return {
37− enabled: true,
38− authorizationServer,
39− jwks,
40− };
41−}
42−
43−function parseBearerToken(request: Request): string | null {
44− const header = request.headers.get("authorization");
45− if (!header || !header.startsWith("Bearer ")) return null;
46− const token = header.slice("Bearer ".length).trim();
47− return token.length > 0 ? token : null;
48−}
49−
50−function resourceMetadataUrl(request: Request): string {
51− const url = new URL(request.url);
52− return `${url.origin}/.well-known/oauth-protected-resource`;
53−}
54−
55−function unauthorizedMcpResponse(request: Request, message: string): Response {
56− const challenge = [
57− 'Bearer error="unauthorized"',
58− 'error_description="Authorization needed"',
59− `resource_metadata="${resourceMetadataUrl(request)}"`,
60− ].join(", ");
61−
62− return Response.json(
63− { error: message },
64− {
65− status: 401,
66− headers: {
67− "WWW-Authenticate": challenge,
68− },
69− },
70− );
71−}
72−
73−async function verifyMcpToken(
74− request: Request,
75− config: ReturnType<typeof getMcpAuthConfig>,
76−): Promise<{ subject: string } | null> {
77− if (!config.enabled || !config.authorizationServer || !config.jwks) {
78− return null;
79− }
80−
81− const token = parseBearerToken(request);
82− if (!token) {
83− return null;
84− }
85−
86− try {
87− const { payload } = await jwtVerify(token, config.jwks, {
88− issuer: config.authorizationServer,
89− });
90−
91− if (typeof payload.sub !== "string" || payload.sub.length === 0) {
92− return null;
93− }
94−
95− return { subject: payload.sub };
96− } catch {
97− return null;
98− }
99−}
100−
101−/**
102− * Validated boundary for external workspace ID strings.
103− * Convex IDs are opaque strings at runtime; this cast is the single
104− * validated entry point so downstream code never needs ad-hoc casts.
105− */
106−function parseWorkspaceId(raw: string): Id<"workspaces"> {
107− return raw as Id<"workspaces">;
108−}
109−
110−function parseMcpContext(url: URL): {
111− workspaceId: Id<"workspaces">;
112− clientId?: string;
113− sessionId?: string;
114−} | undefined {
115− const raw = url.searchParams.get("workspaceId");
116− if (!raw) return undefined;
117− const workspaceId = parseWorkspaceId(raw);
118− const clientId = url.searchParams.get("clientId") ?? undefined;
119− const sessionId = url.searchParams.get("sessionId") ?? undefined;
120− return { workspaceId, clientId, sessionId };
121−}
122−
123−const mcpHandler = httpAction(async (ctx, request) => {
124− const url = new URL(request.url);
125− const mcpAuthConfig = getMcpAuthConfig();
126− const auth = await verifyMcpToken(request, mcpAuthConfig);
127− const requestedContext = parseMcpContext(url);
128−
129− if (mcpAuthConfig.enabled && !requestedContext) {
130− return Response.json(
131− { error: "workspaceId query parameter is required when MCP OAuth is enabled" },
132− { status: 400 },
133− );
134− }
135−
136− let context: McpWorkspaceContext | undefined;
137− if (requestedContext) {
138− try {
139− if (mcpAuthConfig.enabled && auth?.subject) {
140− const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForWorkosSubject, {
141− workspaceId: requestedContext.workspaceId,
142− subject: auth.subject,
143− });
144−
145− context = {
146− workspaceId: requestedContext.workspaceId,
147− actorId: access.actorId,
148− clientId: requestedContext.clientId,
149− };
150− } else {
151− if (mcpAuthConfig.enabled && !requestedContext.sessionId) {
152− return unauthorizedMcpResponse(request, "No valid bearer token provided.");
153− }
154−
155− const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {
156− workspaceId: requestedContext.workspaceId,
157− sessionId: requestedContext.sessionId,
158− });
159−
160− if (mcpAuthConfig.enabled && access.provider !== "anonymous") {
161− return unauthorizedMcpResponse(
162− request,
163− "Bearer token required for non-anonymous sessions.",
164− );
165− }
166−
167− context = {
168− workspaceId: requestedContext.workspaceId,
169− actorId: access.actorId,
170− clientId: requestedContext.clientId,
171− sessionId: requestedContext.sessionId,
172− };
173− }
174− } catch (error) {
175− return Response.json(
176− { error: error instanceof Error ? error.message : "Workspace authorization failed" },
177− { status: 403 },
178− );
179− }
180− }
181−
182− const service = {
183− createTask: async (input: {
184− code: string;
185− timeoutMs?: number;
186− runtimeId?: string;
187− metadata?: Record<string, unknown>;
188− workspaceId: Id<"workspaces">;
189− actorId: string;
190− clientId?: string;
191− }) => {
192− return (await ctx.runMutation(internal.executor.createTaskInternal, {
193− ...input,
194− })) as { task: TaskRecord };
195− },
196− getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => {
197− if (workspaceId) {
198− return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId })) as TaskRecord | null;
199− }
200− return null;
201− },
202− subscribe: () => {
203− return () => {};
204− },
205− bootstrapAnonymousContext: async (sessionId?: string) => {
206− return (await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId })) as AnonymousContext;
207− },
208− listTools: async (toolContext?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => {
209− if (!toolContext) {
210− return [];
211− }
212−
213− return (await ctx.runAction(internal.executorNode.listToolsInternal, { ...toolContext })) as ToolDescriptor[];
214− },
215− listToolsForTypecheck: async (toolContext: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => {
216− const result = await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, { ...toolContext }) as {
217− tools: ToolDescriptor[];
218− dtsUrls?: Record<string, string>;
219− };
220−
221− return {
222− tools: result.tools,
223− dtsUrls: result.dtsUrls ?? {},
224− };
225− },
226− typecheckRunCode: async (input: {
227− code: string;
228− workspaceId: Id<"workspaces">;
229− actorId?: string;
230− clientId?: string;
231− }) => {
232− const result = await ctx.runAction(internal.mcpNode.typecheckRunCodeInternal, {
233− code: input.code,
234− workspaceId: input.workspaceId,
235− actorId: input.actorId,
236− clientId: input.clientId,
237− });
238− return result;
239− },
240− listPendingApprovals: async (workspaceId: Id<"workspaces">) => {
241− return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[];
242− },
243− resolveApproval: async (input: {
244− workspaceId: Id<"workspaces">;
245− approvalId: string;
246− decision: "approved" | "denied";
247− reviewerId?: string;
248− reason?: string;
249− }) => {
250− return await ctx.runMutation(internal.executor.resolveApprovalInternal, {
251− ...input,
252− });
253− },
254− };
255−
256− return await handleMcpRequest(service, request, context);
257−});
258−
259−const oauthProtectedResourceHandler = httpAction(async (_ctx, request) => {
260− const mcpAuthConfig = getMcpAuthConfig();
261− if (!mcpAuthConfig.enabled || !mcpAuthConfig.authorizationServer) {
262− return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
263− }
264−
265− const url = new URL(request.url);
266− return Response.json({
267− resource: `${url.origin}/mcp`,
268− authorization_servers: [mcpAuthConfig.authorizationServer],
269− bearer_methods_supported: ["header"],
270− });
271−});
272−
273−const oauthAuthorizationServerProxyHandler = httpAction(async (_ctx, request) => {
274− const mcpAuthConfig = getMcpAuthConfig();
275− if (!mcpAuthConfig.enabled || !mcpAuthConfig.authorizationServer) {
276− return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
277− }
278−
279− const upstream = new URL("/.well-known/oauth-authorization-server", mcpAuthConfig.authorizationServer);
280− const response = await fetch(upstream.toString(), {
281− headers: { accept: "application/json" },
282− });
283−
284− const text = await response.text();
285− return new Response(text, {
286− status: response.status,
287− headers: {
288− "content-type": response.headers.get("content-type") ?? "application/json",
289− },
290− });
291−});
292−
29317 authKit.registerRoutes(http);
29418 registerStripeRoutes(http, components.stripe, {
29519 webhookPath: "/stripe/webhook",
@@ -298,7 +22,12 @@ registerStripeRoutes(http, components.stripe, { · 2 unmodified lines
29822 http.route({ path: "/mcp", method: "POST", handler: mcpHandler });
29923 http.route({ path: "/mcp", method: "GET", handler: mcpHandler });
30024 http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler });
25+
30126 http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler });
302−http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerProxyHandler });
27+http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerHandler });
28+http.route({ path: "/oauth2/jwks", method: "GET", handler: oauthJwksHandler });
29+http.route({ path: "/register", method: "POST", handler: oauthRegisterHandler });
30+http.route({ path: "/authorize", method: "GET", handler: oauthAuthorizeHandler });
31+http.route({ path: "/token", method: "POST", handler: oauthTokenHandler });
30332
30433 export default http;
executor/packages/convex/http/action_oauth_storage.ts+136−0
@@ -0,0 +1,136 @@
1+import { internal } from "../_generated/api";
2+import type { ActionCtx } from "../_generated/server";
3+import type {
4+ AnonOAuthClientRegistration,
5+ OAuthStorage,
6+ StoredSigningKey,
7+} from "../../core/src/anonymous-oauth";
8+
9+export class ActionOAuthStorage implements OAuthStorage {
10+ constructor(private readonly ctx: ActionCtx) {}
11+
12+ async getActiveSigningKey(): Promise<StoredSigningKey | null> {
13+ const result = await this.ctx.runQuery(
14+ internal.database.getActiveAnonymousOauthSigningKey,
15+ {},
16+ );
17+
18+ if (!result) return null;
19+
20+ return {
21+ keyId: result.keyId,
22+ algorithm: result.algorithm,
23+ privateKeyJwk: result.privateKeyJwk,
24+ publicKeyJwk: result.publicKeyJwk,
25+ };
26+ }
27+
28+ async storeSigningKey(key: StoredSigningKey): Promise<void> {
29+ await this.ctx.runMutation(
30+ internal.database.storeAnonymousOauthSigningKey,
31+ {
32+ keyId: key.keyId,
33+ algorithm: key.algorithm,
34+ privateKeyJwk: key.privateKeyJwk,
35+ publicKeyJwk: key.publicKeyJwk,
36+ },
37+ );
38+ }
39+
40+ async registerClient(
41+ registration: AnonOAuthClientRegistration,
42+ ): Promise<AnonOAuthClientRegistration> {
43+ const result = await this.ctx.runMutation(
44+ internal.database.registerAnonymousOauthClient,
45+ {
46+ clientId: registration.client_id,
47+ clientName: registration.client_name,
48+ redirectUris: registration.redirect_uris,
49+ },
50+ );
51+
52+ return {
53+ client_id: result.client_id,
54+ client_name: result.client_name ?? undefined,
55+ redirect_uris: result.redirect_uris,
56+ created_at: result.created_at,
57+ };
58+ }
59+
60+ async getClient(clientId: string): Promise<AnonOAuthClientRegistration | null> {
61+ const result = await this.ctx.runQuery(
62+ internal.database.getAnonymousOauthClient,
63+ { clientId },
64+ );
65+
66+ if (!result) return null;
67+
68+ return {
69+ client_id: result.client_id,
70+ client_name: result.client_name ?? undefined,
71+ redirect_uris: result.redirect_uris,
72+ created_at: result.created_at,
73+ };
74+ }
75+
76+ async storeAuthorizationCode(code: {
77+ code: string;
78+ clientId: string;
79+ redirectUri: string;
80+ codeChallenge: string;
81+ codeChallengeMethod: string;
82+ actorId: string;
83+ tokenClaims?: Record<string, unknown>;
84+ expiresAt: number;
85+ createdAt: number;
86+ }): Promise<void> {
87+ await this.ctx.runMutation(internal.database.storeAnonymousOauthAuthorizationCode, {
88+ ...code,
89+ });
90+ }
91+
92+ async consumeAuthorizationCode(code: string): Promise<{
93+ code: string;
94+ clientId: string;
95+ redirectUri: string;
96+ codeChallenge: string;
97+ codeChallengeMethod: string;
98+ actorId: string;
99+ tokenClaims?: Record<string, unknown>;
100+ expiresAt: number;
101+ createdAt: number;
102+ } | null> {
103+ const result = await this.ctx.runMutation(internal.database.consumeAnonymousOauthAuthorizationCode, {
104+ code,
105+ });
106+
107+ if (!result) {
108+ return null;
109+ }
110+
111+ return {
112+ code: result.code,
113+ clientId: result.clientId,
114+ redirectUri: result.redirectUri,
115+ codeChallenge: result.codeChallenge,
116+ codeChallengeMethod: result.codeChallengeMethod,
117+ actorId: result.actorId,
118+ tokenClaims: result.tokenClaims ?? undefined,
119+ expiresAt: result.expiresAt,
120+ createdAt: result.createdAt,
121+ };
122+ }
123+
124+ async purgeExpiredAuthorizationCodes(now: number): Promise<number> {
125+ const result = await this.ctx.runMutation(internal.database.purgeExpiredAnonymousOauthAuthorizationCodes, {
126+ now,
127+ });
128+ return result.purged;
129+ }
130+
131+ async countAuthorizationCodes(): Promise<number> {
132+ const result = await this.ctx.runQuery(internal.database.countAnonymousOauthAuthorizationCodes, {
133+ });
134+ return result.count;
135+ }
136+}
executor/packages/convex/http/mcp_auth.ts+239−0
@@ -0,0 +1,239 @@
1+import { createRemoteJWKSet, jwtVerify } from "jose";
2+import type { Id } from "../_generated/dataModel";
3+import type { ActionCtx } from "../_generated/server";
4+import {
5+ AnonymousOAuthServer,
6+} from "../../core/src/anonymous-oauth";
7+import { ActionOAuthStorage } from "./action_oauth_storage";
8+
9+export const ANON_WORKSPACE_CLAIM = "workspace_id";
10+export const ANON_SESSION_CLAIM = "session_id";
11+
12+export type McpAuthConfig = {
13+ enabled: boolean;
14+ anonymousEnabled: boolean;
15+ authorizationServer: string | null;
16+ jwks: ReturnType<typeof createRemoteJWKSet> | null;
17+};
18+
19+export type VerifiedMcpToken =
20+ | { provider: "workos"; subject: string }
21+ | { provider: "anonymous"; subject: string; workspaceId?: Id<"workspaces">; sessionId?: string };
22+
23+export type ParsedMcpContext = {
24+ workspaceId?: Id<"workspaces">;
25+ clientId?: string;
26+ sessionId?: string;
27+};
28+
29+export function parseWorkspaceId(raw: string): Id<"workspaces"> {
30+ return raw as Id<"workspaces">;
31+}
32+
33+export function getMcpAuthorizationServer(): string | null {
34+ return process.env.MCP_AUTHORIZATION_SERVER
35+ ?? process.env.MCP_AUTHORIZATION_SERVER_URL
36+ ?? process.env.WORKOS_AUTHKIT_ISSUER
37+ ?? process.env.WORKOS_AUTHKIT_DOMAIN
38+ ?? null;
39+}
40+
41+export function getMcpAuthConfig(): McpAuthConfig {
42+ const authorizationServer = getMcpAuthorizationServer();
43+ const anonymousEnabled = process.env.MCP_ENABLE_ANONYMOUS_OAUTH === "1" || Boolean(authorizationServer);
44+ if (!authorizationServer && !anonymousEnabled) {
45+ return {
46+ enabled: false,
47+ anonymousEnabled: false,
48+ authorizationServer: null,
49+ jwks: null,
50+ };
51+ }
52+
53+ const jwks = authorizationServer
54+ ? createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer))
55+ : null;
56+
57+ return {
58+ enabled: true,
59+ anonymousEnabled,
60+ authorizationServer,
61+ jwks,
62+ };
63+}
64+
65+export function isAnonymousSessionId(sessionId?: string): boolean {
66+ if (!sessionId) return false;
67+ return sessionId.startsWith("anon_session_") || sessionId.startsWith("mcp_");
68+}
69+
70+export function selectMcpAuthProvider(
71+ request: Request,
72+ config: McpAuthConfig,
73+): "workos" | "anonymous" | null {
74+ if (!config.enabled) {
75+ return null;
76+ }
77+
78+ const url = new URL(request.url);
79+ const sessionId = url.searchParams.get("sessionId") ?? undefined;
80+ if (config.anonymousEnabled && isAnonymousSessionId(sessionId)) {
81+ return "anonymous";
82+ }
83+
84+ if (config.authorizationServer) {
85+ return "workos";
86+ }
87+
88+ if (config.anonymousEnabled) {
89+ return "anonymous";
90+ }
91+
92+ return null;
93+}
94+
95+export async function getAnonymousOAuthServer(
96+ ctx: ActionCtx,
97+ request: Request,
98+): Promise<AnonymousOAuthServer> {
99+ const issuer = new URL(request.url).origin;
100+ const server = new AnonymousOAuthServer({
101+ issuer,
102+ storage: new ActionOAuthStorage(ctx),
103+ });
104+ await server.init();
105+ return server;
106+}
107+
108+function parseBearerToken(request: Request): string | null {
109+ const header = request.headers.get("authorization");
110+ if (!header || !header.startsWith("Bearer ")) return null;
111+ const token = header.slice("Bearer ".length).trim();
112+ return token.length > 0 ? token : null;
113+}
114+
115+function resourceMetadataUrl(request: Request): string {
116+ const url = new URL(request.url);
117+ const metadata = new URL("/.well-known/oauth-protected-resource", url.origin);
118+ metadata.search = url.search;
119+ return metadata.toString();
120+}
121+
122+export function unauthorizedMcpResponse(request: Request, message: string): Response {
123+ const challenge = [
124+ 'Bearer error="unauthorized"',
125+ 'error_description="Authorization needed"',
126+ `resource_metadata="${resourceMetadataUrl(request)}"`,
127+ ].join(", ");
128+
129+ return Response.json(
130+ { error: message },
131+ {
132+ status: 401,
133+ headers: {
134+ "WWW-Authenticate": challenge,
135+ },
136+ },
137+ );
138+}
139+
140+export async function verifyMcpToken(
141+ ctx: ActionCtx,
142+ request: Request,
143+ config: McpAuthConfig,
144+): Promise<VerifiedMcpToken | null> {
145+ if (!config.enabled) {
146+ return null;
147+ }
148+
149+ const token = parseBearerToken(request);
150+ if (!token) {
151+ return null;
152+ }
153+
154+ if (config.authorizationServer && config.jwks) {
155+ try {
156+ const { payload } = await jwtVerify(token, config.jwks, {
157+ issuer: config.authorizationServer,
158+ });
159+
160+ if (typeof payload.sub === "string" && payload.sub.length > 0) {
161+ const providerClaim = typeof payload.provider === "string" ? payload.provider : undefined;
162+ if (providerClaim !== "anonymous") {
163+ return {
164+ provider: "workos",
165+ subject: payload.sub,
166+ };
167+ }
168+ }
169+ } catch {
170+ // Fall through to anonymous-token verification.
171+ }
172+ }
173+
174+ try {
175+ const anonymousOauthServer = await getAnonymousOAuthServer(ctx, request);
176+ const verified = await anonymousOauthServer.verifyToken(token);
177+ if (!verified || verified.provider !== "anonymous") {
178+ return null;
179+ }
180+
181+ const workspaceClaim = verified.claims[ANON_WORKSPACE_CLAIM];
182+ const sessionClaim = verified.claims[ANON_SESSION_CLAIM];
183+
184+ return {
185+ provider: "anonymous",
186+ subject: verified.sub,
187+ workspaceId: typeof workspaceClaim === "string" ? parseWorkspaceId(workspaceClaim) : undefined,
188+ sessionId: typeof sessionClaim === "string" ? sessionClaim : undefined,
189+ };
190+ } catch {
191+ return null;
192+ }
193+}
194+
195+export function parseMcpContext(url: URL): ParsedMcpContext | undefined {
196+ const raw = url.searchParams.get("workspaceId");
197+ const workspaceId = raw ? parseWorkspaceId(raw) : undefined;
198+ const clientId = url.searchParams.get("clientId") ?? undefined;
199+ const sessionId = url.searchParams.get("sessionId") ?? undefined;
200+ if (!workspaceId && !clientId && !sessionId) {
201+ return undefined;
202+ }
203+ return { workspaceId, clientId, sessionId };
204+}
205+
206+export function parseAnonymousAuthorizeContext(params: URLSearchParams): {
207+ workspaceId: Id<"workspaces">;
208+ sessionId: string;
209+} | null {
210+ const directWorkspaceId = params.get("workspaceId");
211+ const directSessionId = params.get("sessionId");
212+ if (directWorkspaceId && directSessionId && isAnonymousSessionId(directSessionId)) {
213+ return {
214+ workspaceId: parseWorkspaceId(directWorkspaceId),
215+ sessionId: directSessionId,
216+ };
217+ }
218+
219+ const resource = params.get("resource");
220+ if (!resource) {
221+ return null;
222+ }
223+
224+ try {
225+ const resourceUrl = new URL(resource);
226+ const workspaceId = resourceUrl.searchParams.get("workspaceId");
227+ const sessionId = resourceUrl.searchParams.get("sessionId");
228+ if (!workspaceId || !sessionId || !isAnonymousSessionId(sessionId)) {
229+ return null;
230+ }
231+
232+ return {
233+ workspaceId: parseWorkspaceId(workspaceId),
234+ sessionId,
235+ };
236+ } catch {
237+ return null;
238+ }
239+}
executor/packages/convex/http/mcp_handler.ts+132−0
@@ -0,0 +1,132 @@
1+import { httpAction } from "../_generated/server";
2+import { internal } from "../_generated/api";
3+import { handleMcpRequest, type McpWorkspaceContext } from "../../core/src/mcp-server";
4+import {
5+ getMcpAuthConfig,
6+ parseMcpContext,
7+ unauthorizedMcpResponse,
8+ verifyMcpToken,
9+} from "./mcp_auth";
10+import { createMcpExecutorService } from "./mcp_service";
11+
12+export const mcpHandler = httpAction(async (ctx, request) => {
13+ const url = new URL(request.url);
14+ const mcpAuthConfig = getMcpAuthConfig();
15+ const auth = await verifyMcpToken(ctx, request, mcpAuthConfig);
16+ const requestedContext = parseMcpContext(url);
17+
18+ if (mcpAuthConfig.enabled && !auth) {
19+ return unauthorizedMcpResponse(request, "No valid bearer token provided.");
20+ }
21+
22+ if (mcpAuthConfig.enabled && auth?.provider === "workos" && !requestedContext?.workspaceId) {
23+ return Response.json(
24+ { error: "workspaceId query parameter is required when MCP OAuth is enabled" },
25+ { status: 400 },
26+ );
27+ }
28+
29+ let context: McpWorkspaceContext | undefined;
30+ const hasRequestedWorkspace = Boolean(requestedContext?.workspaceId);
31+ const hasAnonymousTokenContext = auth?.provider === "anonymous" && Boolean(auth.workspaceId || auth.sessionId);
32+
33+ if (hasRequestedWorkspace || hasAnonymousTokenContext) {
34+ try {
35+ if (auth?.provider === "workos") {
36+ const workspaceId = requestedContext?.workspaceId;
37+ if (!workspaceId) {
38+ return Response.json(
39+ { error: "workspaceId query parameter is required when MCP OAuth is enabled" },
40+ { status: 400 },
41+ );
42+ }
43+
44+ const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForWorkosSubject, {
45+ workspaceId,
46+ subject: auth.subject,
47+ });
48+
49+ context = {
50+ workspaceId,
51+ actorId: access.actorId,
52+ clientId: requestedContext?.clientId,
53+ };
54+ } else if (auth?.provider === "anonymous") {
55+ const workspaceId = requestedContext?.workspaceId ?? auth.workspaceId;
56+ const sessionId = requestedContext?.sessionId ?? auth.sessionId;
57+
58+ if (!workspaceId || !sessionId) {
59+ return unauthorizedMcpResponse(
60+ request,
61+ "Anonymous OAuth token must include workspace and session context.",
62+ );
63+ }
64+
65+ if (requestedContext?.workspaceId && auth.workspaceId && requestedContext.workspaceId !== auth.workspaceId) {
66+ return unauthorizedMcpResponse(request, "Anonymous token workspace does not match requested workspace.");
67+ }
68+ if (requestedContext?.sessionId && auth.sessionId && requestedContext.sessionId !== auth.sessionId) {
69+ return unauthorizedMcpResponse(request, "Anonymous token session does not match requested session.");
70+ }
71+
72+ const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {
73+ workspaceId,
74+ sessionId,
75+ });
76+
77+ if (access.provider !== "anonymous") {
78+ return unauthorizedMcpResponse(request, "Anonymous OAuth token requires an anonymous session.");
79+ }
80+ if (access.actorId !== auth.subject) {
81+ return unauthorizedMcpResponse(request, "Anonymous token subject does not match session actor.");
82+ }
83+
84+ context = {
85+ workspaceId,
86+ actorId: access.actorId,
87+ clientId: requestedContext?.clientId,
88+ sessionId,
89+ };
90+ } else {
91+ const workspaceId = requestedContext?.workspaceId;
92+ if (!workspaceId) {
93+ return Response.json(
94+ { error: "workspaceId query parameter is required when MCP OAuth is enabled" },
95+ { status: 400 },
96+ );
97+ }
98+
99+ if (mcpAuthConfig.enabled && !requestedContext?.sessionId) {
100+ return unauthorizedMcpResponse(request, "No valid bearer token provided.");
101+ }
102+
103+ const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {
104+ workspaceId,
105+ sessionId: requestedContext?.sessionId,
106+ });
107+
108+ if (mcpAuthConfig.enabled && access.provider !== "anonymous") {
109+ return unauthorizedMcpResponse(
110+ request,
111+ "Bearer token required for non-anonymous sessions.",
112+ );
113+ }
114+
115+ context = {
116+ workspaceId,
117+ actorId: access.actorId,
118+ clientId: requestedContext?.clientId,
119+ sessionId: requestedContext?.sessionId,
120+ };
121+ }
122+ } catch (error) {
123+ return Response.json(
124+ { error: error instanceof Error ? error.message : "Workspace authorization failed" },
125+ { status: 403 },
126+ );
127+ }
128+ }
129+
130+ const service = createMcpExecutorService(ctx);
131+ return await handleMcpRequest(service, request, context);
132+});
executor/packages/convex/http/mcp_service.ts+85−0
@@ -0,0 +1,85 @@
1+import type { ActionCtx } from "../_generated/server";
2+import { internal } from "../_generated/api";
3+import type { Id } from "../_generated/dataModel";
4+import type {
5+ AnonymousContext,
6+ PendingApprovalRecord,
7+ TaskRecord,
8+ ToolDescriptor,
9+} from "../../core/src/types";
10+
11+export function createMcpExecutorService(ctx: ActionCtx) {
12+ return {
13+ createTask: async (input: {
14+ code: string;
15+ timeoutMs?: number;
16+ runtimeId?: string;
17+ metadata?: Record<string, unknown>;
18+ workspaceId: Id<"workspaces">;
19+ actorId: string;
20+ clientId?: string;
21+ }) => {
22+ return (await ctx.runMutation(internal.executor.createTaskInternal, {
23+ ...input,
24+ })) as { task: TaskRecord };
25+ },
26+ getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => {
27+ if (workspaceId) {
28+ return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId })) as TaskRecord | null;
29+ }
30+ return null;
31+ },
32+ subscribe: () => {
33+ return () => {};
34+ },
35+ bootstrapAnonymousContext: async (sessionId?: string) => {
36+ return (await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId })) as AnonymousContext;
37+ },
38+ listTools: async (toolContext?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => {
39+ if (!toolContext) {
40+ return [];
41+ }
42+
43+ return (await ctx.runAction(internal.executorNode.listToolsInternal, { ...toolContext })) as ToolDescriptor[];
44+ },
45+ listToolsForTypecheck: async (toolContext: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => {
46+ const result = await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, { ...toolContext }) as {
47+ tools: ToolDescriptor[];
48+ dtsUrls?: Record<string, string>;
49+ };
50+
51+ return {
52+ tools: result.tools,
53+ dtsUrls: result.dtsUrls ?? {},
54+ };
55+ },
56+ typecheckRunCode: async (input: {
57+ code: string;
58+ workspaceId: Id<"workspaces">;
59+ actorId?: string;
60+ clientId?: string;
61+ }) => {
62+ const result = await ctx.runAction(internal.mcpNode.typecheckRunCodeInternal, {
63+ code: input.code,
64+ workspaceId: input.workspaceId,
65+ actorId: input.actorId,
66+ clientId: input.clientId,
67+ });
68+ return result;
69+ },
70+ listPendingApprovals: async (workspaceId: Id<"workspaces">) => {
71+ return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[];
72+ },
73+ resolveApproval: async (input: {
74+ workspaceId: Id<"workspaces">;
75+ approvalId: string;
76+ decision: "approved" | "denied";
77+ reviewerId?: string;
78+ reason?: string;
79+ }) => {
80+ return await ctx.runMutation(internal.executor.resolveApprovalInternal, {
81+ ...input,
82+ });
83+ },
84+ };
85+}
executor/packages/convex/http/oauth_handlers.ts+213−0
@@ -0,0 +1,213 @@
1+import { httpAction } from "../_generated/server";
2+import { internal } from "../_generated/api";
3+import { OAuthBadRequest } from "../../core/src/anonymous-oauth";
4+import {
5+ ANON_SESSION_CLAIM,
6+ ANON_WORKSPACE_CLAIM,
7+ getAnonymousOAuthServer,
8+ getMcpAuthConfig,
9+ isAnonymousSessionId,
10+ parseAnonymousAuthorizeContext,
11+ selectMcpAuthProvider,
12+} from "./mcp_auth";
13+
14+export const oauthProtectedResourceHandler = httpAction(async (_ctx, request) => {
15+ const mcpAuthConfig = getMcpAuthConfig();
16+ const url = new URL(request.url);
17+
18+ let resource = new URL("/mcp", url.origin);
19+ resource.search = url.search;
20+ const resourceHint = url.searchParams.get("resource");
21+ if (resourceHint) {
22+ try {
23+ const parsed = new URL(resourceHint);
24+ if (parsed.origin === url.origin && parsed.pathname === "/mcp") {
25+ resource = parsed;
26+ }
27+ } catch {
28+ // Ignore malformed resource hint and fall back to request URL params.
29+ }
30+ }
31+
32+ let provider = selectMcpAuthProvider(request, mcpAuthConfig);
33+ const hintedSessionId = resource.searchParams.get("sessionId") ?? undefined;
34+ const hintedWorkspaceId = resource.searchParams.get("workspaceId") ?? undefined;
35+
36+ if (mcpAuthConfig.anonymousEnabled && isAnonymousSessionId(hintedSessionId)) {
37+ provider = "anonymous";
38+ }
39+
40+ if (!provider && mcpAuthConfig.anonymousEnabled) {
41+ provider = "anonymous";
42+ }
43+
44+ if (provider === "workos" && !hintedWorkspaceId && mcpAuthConfig.anonymousEnabled) {
45+ // Queryless discovery calls are ambiguous; prefer anonymous so clients
46+ // can discover a first-party auth server without being redirected to WorkOS.
47+ provider = "anonymous";
48+ }
49+
50+ if (!provider) {
51+ return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
52+ }
53+
54+ const authorizationServer = provider === "anonymous"
55+ ? url.origin
56+ : mcpAuthConfig.authorizationServer;
57+
58+ if (!authorizationServer) {
59+ return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
60+ }
61+
62+ return Response.json({
63+ resource: resource.toString(),
64+ authorization_servers: [authorizationServer],
65+ bearer_methods_supported: ["header"],
66+ });
67+});
68+
69+export const oauthAuthorizationServerHandler = httpAction(async (_ctx, request) => {
70+ const mcpAuthConfig = getMcpAuthConfig();
71+ if (!mcpAuthConfig.enabled) {
72+ return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
73+ }
74+
75+ // Clients usually call this endpoint without workspace/session params.
76+ // When anonymous OAuth is enabled, default to our self-issued metadata.
77+ if (mcpAuthConfig.anonymousEnabled) {
78+ const oauthServer = await getAnonymousOAuthServer(_ctx, request);
79+ return Response.json(oauthServer.getMetadata());
80+ }
81+
82+ if (!mcpAuthConfig.authorizationServer) {
83+ return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
84+ }
85+
86+ const upstream = new URL("/.well-known/oauth-authorization-server", mcpAuthConfig.authorizationServer);
87+ const response = await fetch(upstream.toString(), {
88+ headers: { accept: "application/json" },
89+ });
90+
91+ const text = await response.text();
92+ return new Response(text, {
93+ status: response.status,
94+ headers: {
95+ "content-type": response.headers.get("content-type") ?? "application/json",
96+ },
97+ });
98+});
99+
100+export const oauthJwksHandler = httpAction(async (_ctx, request) => {
101+ const mcpAuthConfig = getMcpAuthConfig();
102+ if (!mcpAuthConfig.enabled) {
103+ return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
104+ }
105+
106+ const oauthServer = await getAnonymousOAuthServer(_ctx, request);
107+ return Response.json(oauthServer.getJwks(), {
108+ headers: {
109+ "cache-control": "public, max-age=3600",
110+ },
111+ });
112+});
113+
114+export const oauthRegisterHandler = httpAction(async (_ctx, request) => {
115+ const mcpAuthConfig = getMcpAuthConfig();
116+ if (!mcpAuthConfig.enabled) {
117+ return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
118+ }
119+
120+ let body: { redirect_uris?: string[]; client_name?: string };
121+ try {
122+ body = await request.json() as { redirect_uris?: string[]; client_name?: string };
123+ } catch {
124+ return Response.json({ error: "invalid_client_metadata", error_description: "Invalid JSON body" }, { status: 400 });
125+ }
126+
127+ try {
128+ const oauthServer = await getAnonymousOAuthServer(_ctx, request);
129+ const registration = await oauthServer.registerClient(body);
130+ return Response.json(registration, { status: 201 });
131+ } catch (error) {
132+ if (error instanceof OAuthBadRequest) {
133+ return Response.json(
134+ { error: "invalid_client_metadata", error_description: error.message },
135+ { status: 400 },
136+ );
137+ }
138+ throw error;
139+ }
140+});
141+
142+export const oauthAuthorizeHandler = httpAction(async (ctx, request) => {
143+ const mcpAuthConfig = getMcpAuthConfig();
144+ if (!mcpAuthConfig.enabled) {
145+ return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
146+ }
147+
148+ const oauthServer = await getAnonymousOAuthServer(ctx, request);
149+ const url = new URL(request.url);
150+ const anonymousContext = parseAnonymousAuthorizeContext(url.searchParams);
151+
152+ try {
153+ if (!anonymousContext) {
154+ throw new OAuthBadRequest("resource must include workspaceId and anonymous sessionId");
155+ }
156+
157+ const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {
158+ workspaceId: anonymousContext.workspaceId,
159+ sessionId: anonymousContext.sessionId,
160+ });
161+
162+ if (access.provider !== "anonymous") {
163+ throw new OAuthBadRequest("Anonymous OAuth requires an anonymous session");
164+ }
165+
166+ const { redirectTo } = await oauthServer.authorize(url.searchParams, {
167+ actorId: access.actorId,
168+ tokenClaims: {
169+ [ANON_WORKSPACE_CLAIM]: anonymousContext.workspaceId,
170+ [ANON_SESSION_CLAIM]: anonymousContext.sessionId,
171+ },
172+ });
173+
174+ return Response.redirect(redirectTo, 302);
175+ } catch (error) {
176+ if (error instanceof OAuthBadRequest) {
177+ return Response.json(
178+ { error: "invalid_request", error_description: error.message },
179+ { status: 400 },
180+ );
181+ }
182+ return Response.json(
183+ { error: error instanceof Error ? error.message : "Workspace authorization failed" },
184+ { status: 403 },
185+ );
186+ }
187+});
188+
189+export const oauthTokenHandler = httpAction(async (_ctx, request) => {
190+ const mcpAuthConfig = getMcpAuthConfig();
191+ if (!mcpAuthConfig.enabled) {
192+ return Response.json({ error: "MCP OAuth is not configured" }, { status: 404 });
193+ }
194+
195+ try {
196+ const oauthServer = await getAnonymousOAuthServer(_ctx, request);
197+ const body = new URLSearchParams(await request.text());
198+ const tokens = await oauthServer.exchangeToken(body);
199+ return Response.json(tokens, {
200+ headers: {
201+ "cache-control": "no-store",
202+ },
203+ });
204+ } catch (error) {
205+ if (error instanceof OAuthBadRequest) {
206+ return Response.json(
207+ { error: "invalid_grant", error_description: error.message },
208+ { status: 400 },
209+ );
210+ }
211+ throw error;
212+ }
213+});
executor/packages/convex/mcpNode.ts+2−2
@@ -3,7 +3,7 @@ · 2 unmodified lines
33 import { v } from "convex/values";
44 import { internal } from "./_generated/api";
55 import { internalAction } from "./_generated/server";
6−import { loadSourceDtsByUrlCached } from "../core/src/dts-loader";
6+import { loadSourceDtsByUrl } from "../core/src/dts-loader";
77 import { generateToolDeclarations, typecheckCode } from "../core/src/typechecker";
88 import type { ToolDescriptor } from "../core/src/types";
99
@@ -26,7 +26,7 @@ export const typecheckRunCodeInternal = internalAction({ · 16 unmodified lines
2626 dtsUrls?: Record<string, string>;
2727 };
2828
29− const sourceDtsBySource = await loadSourceDtsByUrlCached(result.dtsUrls ?? {});
29+ const sourceDtsBySource = await loadSourceDtsByUrl(result.dtsUrls ?? {});
3030 const declarations = generateToolDeclarations(result.tools, {
3131 sourceDtsBySource,
3232 });
executor/packages/convex/runtimeCallbacks.ts+3−34
@@ -32,42 +32,12 @@ export const handleToolCall = action({ · 31 unmodified lines
3232 },
3333 });
3434
35−export const appendOutput = mutation({
36− args: {
37− internalSecret: v.string(),
38− runId: v.string(),
39− stream: v.union(v.literal("stdout"), v.literal("stderr")),
40− line: v.string(),
41− timestamp: v.optional(v.number()),
42− },
43− handler: async (ctx, args) => {
44− requireInternalSecret(args.internalSecret);
45−
46− const task = await ctx.runQuery(internal.database.getTask, {
47− taskId: args.runId,
48− });
49− if (!task) {
50− return { ok: false as const, error: `Run not found: ${args.runId}` };
51− }
52−
53− await ctx.runMutation(internal.executor.appendRuntimeOutput, {
54− runId: args.runId,
55− stream: args.stream,
56− line: args.line,
57− timestamp: args.timestamp,
58− });
59−
60− return { ok: true as const };
61− },
62−});
63−
6435 export const completeRun = mutation({
6536 args: {
6637 internalSecret: v.string(),
6738 runId: v.string(),
6839 status: v.union(v.literal("completed"), v.literal("failed"), v.literal("timed_out"), v.literal("denied")),
69− stdout: v.optional(v.string()),
70− stderr: v.optional(v.string()),
40+ result: v.optional(v.any()),
7141 exitCode: v.optional(v.number()),
7242 error: v.optional(v.string()),
7343 durationMs: v.optional(v.number()),
@@ -75,11 +45,10 @@ export const completeRun = mutation({ · 1 unmodified line
7545 handler: async (ctx, args) => {
7646 requireInternalSecret(args.internalSecret);
7747
78− return await ctx.runMutation(internal.executor.completeRuntimeRun, {
48+ return await ctx.runMutation(internal.executor.completeRuntimeRun as any, {
7949 runId: args.runId,
8050 status: args.status,
81− stdout: args.stdout,
82− stderr: args.stderr,
51+ result: args.result,
8352 exitCode: args.exitCode,
8453 error: args.error,
8554 durationMs: args.durationMs,
executor/packages/convex/schema.ts+29−7
@@ -44,7 +44,7 @@ const toolCallStatus = v.union( · 43 unmodified lines
4444 const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
4545 const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));
4646 const credentialProvider = v.union(
47− v.literal("managed"),
47+ v.literal("local-convex"),
4848 v.literal("workos-vault"),
4949 );
5050 const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
@@ -186,8 +186,7 @@ export default defineSchema({ · 135 unmodified lines
186186 timeoutMs: v.number(),
187187 metadata: v.any(),
188188 error: v.optional(v.string()),
189− stdout: v.optional(v.string()),
190− stderr: v.optional(v.string()),
189+ result: v.optional(v.any()),
191190 exitCode: v.optional(v.number()),
192191 createdAt: v.number(),
193192 updatedAt: v.number(),
@@ -219,10 +218,8 @@ export default defineSchema({ · 25 unmodified lines
219218 callId: v.string(),
220219 workspaceId: v.id("workspaces"),
221220 toolPath: v.string(),
222− input: v.any(),
223221 status: toolCallStatus,
224222 approvalId: v.optional(v.string()),
225− output: v.optional(v.any()),
226223 error: v.optional(v.string()),
227224 createdAt: v.number(),
228225 updatedAt: v.number(),
@@ -258,18 +255,23 @@ export default defineSchema({ · 29 unmodified lines
258255 .index("by_workspace_created", ["workspaceId", "createdAt"]),
259256
260257 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>
262260 workspaceId: v.id("workspaces"),
263261 sourceKey: v.string(),
264262 scope: credentialScope,
265263 actorId: v.string(), // account._id or anon_<uuid> (required for composite index)
266264 provider: credentialProvider,
267265 secretJson: v.any(),
266+ overridesJson: v.optional(v.any()),
267+ boundAuthFingerprint: v.optional(v.string()),
268268 createdAt: v.number(),
269269 updatedAt: v.number(),
270270 })
271271 .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"]),
273275
274276 toolSources: defineTable({
275277 sourceId: v.string(), // domain ID: src_<uuid>
@@ -277,6 +279,8 @@ export default defineSchema({ · 1 unmodified line
277279 name: v.string(),
278280 type: toolSourceType,
279281 config: v.any(),
282+ specHash: v.optional(v.string()),
283+ authFingerprint: v.optional(v.string()),
280284 enabled: v.boolean(),
281285 createdAt: v.number(),
282286 updatedAt: v.number(),
@@ -355,4 +359,22 @@ export default defineSchema({ · 72 unmodified lines
355359 createdAt: v.number(),
356360 })
357361 .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"]),
358380 });
executor/packages/convex/workspace.ts+2−1
@@ -5,7 +5,7 @@ import { workspaceMutation, workspaceQuery } from "../core/src/function-builders · 4 unmodified lines
55
66 const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
77 const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
8−const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault"));
8+const credentialProviderValidator = v.union(v.literal("local-convex"), v.literal("workos-vault"));
99 const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
1010
1111 function redactCredential<T extends { secretJson: Record<string, unknown> }>(credential: T): T {
@@ -110,6 +110,7 @@ export const upsertCredential = workspaceMutation({ · 98 unmodified lines
110110 actorId: v.optional(v.string()),
111111 provider: v.optional(credentialProviderValidator),
112112 secretJson: v.any(),
113+ overridesJson: v.optional(v.any()),
113114 },
114115 handler: async (ctx, args) => {
115116 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
1010 called += 1;
1111 return { ok: true };
1212 },
13− emitOutput: () => {},
1413 });
1514
1615 const result = await adapter.invokeTool({
@@ -33,7 +32,6 @@ test("maps approval denied errors to denied result", async () => { · 16 unmodified lines
3332 invokeTool: async () => {
3433 throw new Error(`${APPROVAL_DENIED_PREFIX}approval required`);
3534 },
36− emitOutput: () => {},
3735 });
3836
3937 const result = await adapter.invokeTool({
@@ -49,30 +47,3 @@ test("maps approval denied errors to denied result", async () => { · 9 unmodified lines
4947 error: "approval required",
5048 });
5149 });
52−
53−test("emits output only for matching run id", () => {
54− const lines: string[] = [];
55− const adapter = new InProcessExecutionAdapter({
56− runId: "run_1",
57− invokeTool: async () => null,
58− emitOutput: (event) => {
59− lines.push(`${event.stream}:${event.line}`);
60− },
61− });
62−
63− adapter.emitOutput({
64− runId: "run_other",
65− stream: "stdout",
66− line: "ignored",
67− timestamp: Date.now(),
68− });
69−
70− adapter.emitOutput({
71− runId: "run_1",
72− stream: "stdout",
73− line: "accepted",
74− timestamp: Date.now(),
75− });
76−
77− expect(lines).toEqual(["stdout:accepted"]);
78−});
executor/packages/core/src/adapters/in-process-execution-adapter.ts+0−9
@@ -1,7 +1,6 @@
11 import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../execution-constants";
22 import type {
33 ExecutionAdapter,
4− RuntimeOutputEvent,
54 ToolCallRequest,
65 ToolCallResult,
76 } from "../types";
@@ -10,7 +9,6 @@ import { describeError } from "../utils"; · 2 unmodified lines
109 interface InProcessExecutionAdapterOptions {
1110 runId: string;
1211 invokeTool: (call: ToolCallRequest) => Promise<unknown>;
13− emitOutput: (event: RuntimeOutputEvent) => void;
1412 }
1513
1614 export class InProcessExecutionAdapter implements ExecutionAdapter {
@@ -55,11 +53,4 @@ export class InProcessExecutionAdapter implements ExecutionAdapter { · 38 unmodified lines
5553 };
5654 }
5755 }
58−
59− emitOutput(event: RuntimeOutputEvent): void {
60− if (event.runId !== this.options.runId) {
61− return;
62− }
63− this.options.emitOutput(event);
64− }
6556 }
executor/packages/core/src/anonymous-oauth.test.ts+89−6
@@ -11,7 +11,7 @@ import { · 10 unmodified lines
1111 // ---------------------------------------------------------------------------
1212
1313 function createServer(issuer = "http://localhost:3003"): AnonymousOAuthServer {
14− return new AnonymousOAuthServer({ issuer });
14+ return new AnonymousOAuthServer({ issuer, storage: new InMemoryOAuthStorage() });
1515 }
1616
1717 /** Generate a random PKCE code_verifier (43–128 chars, unreserved). */
@@ -253,6 +253,85 @@ describe("AnonymousOAuthServer", () => { · 235 unmodified lines
253253 expect(verified!.provider).toBe("anonymous");
254254 });
255255
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+
256335 test("code is single-use", async () => {
257336 const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
258337
@@ -298,6 +377,7 @@ describe("AnonymousOAuthServer", () => { · 39 unmodified lines
298377 const shortServer = new AnonymousOAuthServer({
299378 issuer: "http://localhost:3003",
300379 codeExpirySeconds: 0, // expires immediately
380+ storage: new InMemoryOAuthStorage(),
301381 });
302382 await shortServer.init();
303383
@@ -433,20 +513,21 @@ describe("AnonymousOAuthServer", () => { · 129 unmodified lines
433513 const s = new AnonymousOAuthServer({
434514 issuer: "http://localhost:3003",
435515 codeExpirySeconds: 0,
516+ storage: new InMemoryOAuthStorage(),
436517 });
437518 await s.init();
438519
439520 // Generate some codes
440521 await registerAndAuthorize(s);
441522 await registerAndAuthorize(s);
442− expect(s.getCodeCount()).toBe(2);
523+ expect(await s.getCodeCount()).toBe(2);
443524
444525 // Wait for them to expire
445526 await new Promise((resolve) => setTimeout(resolve, 10));
446527
447− const purged = s.purgeExpiredCodes();
528+ const purged = await s.purgeExpiredCodes();
448529 expect(purged).toBe(2);
449− expect(s.getCodeCount()).toBe(0);
530+ expect(await s.getCodeCount()).toBe(0);
450531 });
451532 });
452533
@@ -458,13 +539,14 @@ describe("AnonymousOAuthServer", () => { · 5 unmodified lines
458539 issuer: "http://localhost:3003",
459540 maxPendingCodes: 2,
460541 codeExpirySeconds: 300, // long-lived so they don't auto-purge
542+ storage: new InMemoryOAuthStorage(),
461543 });
462544 await s.init();
463545
464546 // Fill up the code slots
465547 await registerAndAuthorize(s);
466548 await registerAndAuthorize(s);
467− expect(s.getCodeCount()).toBe(2);
549+ expect(await s.getCodeCount()).toBe(2);
468550
469551 // Third should be rejected
470552 await expect(registerAndAuthorize(s)).rejects.toThrow(
@@ -477,13 +559,14 @@ describe("AnonymousOAuthServer", () => { · 6 unmodified lines
477559 issuer: "http://localhost:3003",
478560 maxPendingCodes: 2,
479561 codeExpirySeconds: 0, // codes expire immediately
562+ storage: new InMemoryOAuthStorage(),
480563 });
481564 await s.init();
482565
483566 // Fill slots (they expire immediately)
484567 await registerAndAuthorize(s);
485568 await registerAndAuthorize(s);
486− expect(s.getCodeCount()).toBe(2);
569+ expect(await s.getCodeCount()).toBe(2);
487570
488571 // Wait for expiry
489572 await new Promise((resolve) => setTimeout(resolve, 10));
executor/packages/core/src/anonymous-oauth.ts+116−49
@@ -21,10 +21,8 @@ · 20 unmodified lines
2121 *
2222 * Storage is pluggable via the `OAuthStorage` interface:
2323 * - `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,
2525 * so tokens survive gateway restarts
26− *
27− * Authorization codes are always in-memory (short-lived, single-use).
2826 */
2927
3028 import {
@@ -36,7 +34,6 @@ import { · 5 unmodified lines
3634 jwtVerify,
3735 type JWK,
3836 type CryptoKey as JoseCryptoKey,
39− type JWTVerifyGetKey,
4037 } from "jose";
4138
4239 // ---------------------------------------------------------------------------
@@ -57,7 +54,39 @@ interface AuthorizationCode { · 14 unmodified lines
5754 codeChallenge: string;
5855 codeChallengeMethod: string;
5956 actorId: string;
57+ tokenClaims?: Record<string, unknown>;
6058 expiresAt: number;
59+ createdAt: number;
60+}
61+
62+const RESERVED_JWT_CLAIMS = new Set([
63+ "iss",
64+ "sub",
65+ "aud",
66+ "exp",
67+ "nbf",
68+ "iat",
69+ "jti",
70+]);
71+
72+function sanitizeTokenClaims(input?: Record<string, unknown>): Record<string, unknown> | undefined {
73+ if (!input) return undefined;
74+
75+ const sanitized: Record<string, unknown> = {};
76+ for (const [key, value] of Object.entries(input)) {
77+ if (RESERVED_JWT_CLAIMS.has(key)) continue;
78+ if (value === undefined) continue;
79+ sanitized[key] = value;
80+ }
81+
82+ return Object.keys(sanitized).length > 0 ? sanitized : undefined;
83+}
84+
85+export interface AnonymousAuthorizeOptions {
86+ /** Optional actor subject override. Defaults to a fresh `anon_<uuid>`. */
87+ actorId?: string;
88+ /** Optional custom access-token claims (reserved JWT claims are ignored). */
89+ tokenClaims?: Record<string, unknown>;
6190 }
6291
6392 export interface AnonOAuthConfig {
@@ -69,8 +98,8 @@ export interface AnonOAuthConfig { · 5 unmodified lines
6998 codeExpirySeconds?: number;
7099 /** Maximum number of in-flight authorization codes. Default 10 000. */
71100 maxPendingCodes?: number;
72− /** Pluggable storage backend for keys & client registrations. */
73− storage?: OAuthStorage;
101+ /** Pluggable storage backend for keys, clients, and authorization codes. */
102+ storage: OAuthStorage;
74103 }
75104
76105 // ---------------------------------------------------------------------------
@@ -105,6 +134,26 @@ export interface OAuthStorage { · 28 unmodified lines
105134 * Look up an OAuth client by client_id. Returns null if not found.
106135 */
107136 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>;
108157 }
109158
110159 // ---------------------------------------------------------------------------
@@ -114,6 +163,7 @@ export interface OAuthStorage { · 3 unmodified lines
114163 export class InMemoryOAuthStorage implements OAuthStorage {
115164 private signingKey: StoredSigningKey | null = null;
116165 private readonly clients = new Map<string, AnonOAuthClientRegistration>();
166+ private readonly authorizationCodes = new Map<string, AuthorizationCode>();
117167
118168 async getActiveSigningKey(): Promise<StoredSigningKey | null> {
119169 return this.signingKey;
@@ -132,6 +182,34 @@ export class InMemoryOAuthStorage implements OAuthStorage { · 12 unmodified lines
132182 return this.clients.get(clientId) ?? null;
133183 }
134184
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+
135213 /** Test helper: number of registered clients. */
136214 get clientCount(): number {
137215 return this.clients.size;
@@ -153,11 +231,6 @@ export class AnonymousOAuthServer { · 15 unmodified lines
153231 private privateKey!: JoseCryptoKey;
154232 private publicJwk!: JWK;
155233 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>();
161234
162235 constructor(config: AnonOAuthConfig) {
163236 this.issuer = config.issuer.replace(/\/+$/, "");
@@ -165,7 +238,7 @@ export class AnonymousOAuthServer { · 1 unmodified line
165238 this.accessTokenTtlSeconds = config.accessTokenTtlSeconds ?? 24 * 60 * 60;
166239 this.codeExpirySeconds = config.codeExpirySeconds ?? 120;
167240 this.maxPendingCodes = config.maxPendingCodes ?? 10_000;
168− this.storage = config.storage ?? new InMemoryOAuthStorage();
241+ this.storage = config.storage;
169242 }
170243
171244 // -------------------------------------------------------------------------
@@ -176,8 +249,7 @@ export class AnonymousOAuthServer { · 4 unmodified lines
176249 * Initialize the OAuth server.
177250 *
178251 * Attempts to load an existing signing key from storage. If none exists,
179− * generates a new RSA key pair and persists it. Either way, the private
180− * key and public JWK are cached locally for fast in-process operations.
252+ * generates a new RSA key pair and persists it.
181253 */
182254 async init(): Promise<void> {
183255 const existing = await this.storage.getActiveSigningKey();
@@ -205,9 +277,6 @@ export class AnonymousOAuthServer { · 21 unmodified lines
205277 publicKeyJwk: publicJwk,
206278 });
207279 }
208−
209− // Build cached local JWKS for fast token verification
210− this.localJwks = createLocalJWKSet({ keys: [this.publicJwk] });
211280 }
212281
213282 /**
@@ -218,7 +287,6 @@ export class AnonymousOAuthServer { · 4 unmodified lines
218287 this.privateKey = privateKey;
219288 this.keyId = publicJwk.kid ?? `anon_key_${crypto.randomUUID().slice(0, 8)}`;
220289 this.publicJwk = { ...publicJwk, kid: this.keyId, use: "sig", alg: "RS256" };
221− this.localJwks = createLocalJWKSet({ keys: [this.publicJwk] });
222290 }
223291
224292 // -------------------------------------------------------------------------
@@ -292,7 +360,7 @@ export class AnonymousOAuthServer { · 67 unmodified lines
292360 * user to authenticate — we auto-approve and redirect back immediately
293361 * with an authorization code.
294362 */
295− async authorize(params: URLSearchParams): Promise<{ redirectTo: string }> {
363+ async authorize(params: URLSearchParams, options?: AnonymousAuthorizeOptions): Promise<{ redirectTo: string }> {
296364 const responseType = params.get("response_type");
297365 if (responseType !== "code") {
298366 throw new OAuthBadRequest("response_type must be 'code'");
@@ -322,28 +390,32 @@ export class AnonymousOAuthServer { · 23 unmodified lines
322390 throw new OAuthBadRequest("PKCE S256 code_challenge is required");
323391 }
324392
325− // Generate anonymous identity
326− const actorId = `anon_${crypto.randomUUID()}`;
393+ // Generate or bind anonymous identity
394+ const requestedActorId = options?.actorId?.trim();
395+ const actorId = requestedActorId && requestedActorId.length > 0
396+ ? requestedActorId
397+ : `anon_${crypto.randomUUID()}`;
327398
328− // Enforce cap on pending codes to prevent memory exhaustion
329− if (this.codes.size >= this.maxPendingCodes) {
330− // Purge expired codes first; if still over limit, reject
331− this.purgeExpiredCodes();
332− if (this.codes.size >= this.maxPendingCodes) {
399+ // Enforce cap on pending codes to prevent unbounded growth
400+ if (await this.storage.countAuthorizationCodes() >= this.maxPendingCodes) {
401+ await this.storage.purgeExpiredAuthorizationCodes(Date.now());
402+ if (await this.storage.countAuthorizationCodes() >= this.maxPendingCodes) {
333403 throw new OAuthBadRequest("Too many pending authorization requests — try again later");
334404 }
335405 }
336406
337− // Issue authorization code (always in-memory — short-lived, single-use)
407+ // Issue authorization code (persistent, short-lived, single-use)
338408 const code = crypto.randomUUID();
339− this.codes.set(code, {
409+ await this.storage.storeAuthorizationCode({
340410 code,
341411 clientId,
342412 redirectUri,
343413 codeChallenge,
344414 codeChallengeMethod,
345415 actorId,
416+ tokenClaims: sanitizeTokenClaims(options?.tokenClaims),
346417 expiresAt: Date.now() + this.codeExpirySeconds * 1000,
418+ createdAt: Date.now(),
347419 });
348420
349421 // Build redirect URL
@@ -380,14 +452,11 @@ export class AnonymousOAuthServer { · 30 unmodified lines
380452 throw new OAuthBadRequest("code is required");
381453 }
382454
383− const storedCode = this.codes.get(codeValue);
455+ const storedCode = await this.storage.consumeAuthorizationCode(codeValue);
384456 if (!storedCode) {
385457 throw new OAuthBadRequest("invalid or expired code");
386458 }
387459
388− // Consume the code immediately (one-time use)
389− this.codes.delete(codeValue);
390−
391460 if (Date.now() > storedCode.expiresAt) {
392461 throw new OAuthBadRequest("authorization code has expired");
393462 }
@@ -416,11 +485,14 @@ export class AnonymousOAuthServer { · 22 unmodified lines
416485 }
417486
418487 // Mint JWT
419− const accessToken = await new SignJWT({
420− sub: storedCode.actorId,
488+ const accessTokenClaims = {
421489 provider: "anonymous",
422− })
490+ ...(storedCode.tokenClaims ?? {}),
491+ };
492+
493+ const accessToken = await new SignJWT(accessTokenClaims)
423494 .setProtectedHeader({ alg: "RS256", kid: this.keyId })
495+ .setSubject(storedCode.actorId)
424496 .setIssuer(this.issuer)
425497 .setAudience(this.audience)
426498 .setIssuedAt()
@@ -441,9 +513,9 @@ export class AnonymousOAuthServer { · 14 unmodified lines
441513
442514 async verifyToken(
443515 token: string,
444− ): Promise<{ sub: string; provider: string } | null> {
516+ ): Promise<{ sub: string; provider: string; claims: Record<string, unknown> } | null> {
445517 try {
446− const { payload } = await jwtVerify(token, this.localJwks, {
518+ const { payload } = await jwtVerify(token, createLocalJWKSet({ keys: [this.publicJwk] }), {
447519 issuer: this.issuer,
448520 audience: this.audience,
449521 });
@@ -455,6 +527,9 @@ export class AnonymousOAuthServer { · 5 unmodified lines
455527 return {
456528 sub: payload.sub,
457529 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+ ),
458533 };
459534 } catch {
460535 return null;
@@ -469,21 +544,13 @@ export class AnonymousOAuthServer { · 8 unmodified lines
469544 return this.issuer;
470545 }
471546
472− getCodeCount(): number {
473− return this.codes.size;
547+ async getCodeCount(): Promise<number> {
548+ return await this.storage.countAuthorizationCodes();
474549 }
475550
476551 /** Purge expired authorization codes (call periodically). */
477− purgeExpiredCodes(): number {
478− const now = Date.now();
479− let purged = 0;
480− for (const [key, code] of this.codes) {
481− if (now > code.expiresAt) {
482− this.codes.delete(key);
483− purged++;
484− }
485− }
486− return purged;
552+ async purgeExpiredCodes(): Promise<number> {
553+ return await this.storage.purgeExpiredAuthorizationCodes(Date.now());
487554 }
488555 }
489556
executor/packages/core/src/convex-oauth-storage.ts+67−3
@@ -1,9 +1,8 @@
11 /**
22 * Convex-backed storage for the anonymous OAuth server.
33 *
4− * Persists signing keys and client registrations to Convex so they
5− * survive gateway restarts. Keys are cached locally after first load
6− * for fast in-process token verification.
4+ * Persists signing keys, client registrations, and authorization codes
5+ * to Convex so OAuth flows survive gateway restarts.
76 *
87 * All Convex calls include an `internalSecret` argument that is validated
98 * server-side against `EXECUTOR_INTERNAL_TOKEN`, preventing unauthorized
@@ -97,4 +96,69 @@ export class ConvexOAuthStorage implements OAuthStorage { · 87 unmodified lines
9796 created_at: result.created_at,
9897 };
9998 }
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+ }
100164 }
executor/packages/core/src/credential-providers.test.ts+4−4
@@ -1,13 +1,13 @@
11 import { expect, test } from "bun:test";
22 import { resolveCredentialPayload } from "./credential-providers";
33
4−test("managed provider returns stored payload", async () => {
4+test("local provider returns stored payload", async () => {
55 const payload = await resolveCredentialPayload({
6− provider: "managed",
7− secretJson: { token: "managed-token" },
6+ provider: "local-convex",
7+ secretJson: { token: "local-token" },
88 });
99
10− expect(payload).toEqual({ token: "managed-token" });
10+ expect(payload).toEqual({ token: "local-token" });
1111 });
1212
1313 test("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
105105 return value.trim();
106106 }
107107
108−async function resolveManaged(
108+async function resolveLocalConvex(
109109 record: Pick<CredentialRecord, "secretJson">,
110110 ): Promise<CredentialPayload | null> {
111111 return asRecord(record.secretJson);
@@ -135,7 +135,7 @@ async function resolveWorkosVault( · 23 unmodified lines
135135 }
136136
137137 const providers: Record<CredentialProvider, ProviderResolver> = {
138− managed: resolveManaged,
138+ "local-convex": resolveLocalConvex,
139139 "workos-vault": resolveWorkosVault,
140140 };
141141
executor/packages/core/src/dts-loader.test.ts+20−19
@@ -1,7 +1,7 @@
11 import { expect, test } from "bun:test";
2−import { clearDtsUrlCache, loadSourceDtsByUrlCached } from "./dts-loader";
2+import { loadSourceDtsByUrl } from "./dts-loader";
33
4−test("loadSourceDtsByUrlCached reuses cached URL fetches", async () => {
4+test("loadSourceDtsByUrl fetches declarations for each source key", async () => {
55 let requestCount = 0;
66 const server = Bun.serve({
77 port: 0,
@@ -14,50 +14,51 @@ test("loadSourceDtsByUrlCached reuses cached URL fetches", async () => { · 6 unmodified lines
1414 });
1515
1616 try {
17− clearDtsUrlCache();
1817 const url = `http://127.0.0.1:${server.port}/types.d.ts`;
1918
20− const first = await loadSourceDtsByUrlCached({
19+ const first = await loadSourceDtsByUrl({
2120 "openapi:one": url,
2221 "openapi:two": url,
2322 });
24− const second = await loadSourceDtsByUrlCached({
23+ const second = await loadSourceDtsByUrl({
2524 "openapi:one": url,
2625 });
2726
2827 expect(first["openapi:one"]).toContain("interface operations");
2928 expect(first["openapi:two"]).toContain("interface operations");
3029 expect(second["openapi:one"]).toContain("interface operations");
31− expect(requestCount).toBe(1);
30+ expect(requestCount).toBe(3);
3231 } finally {
3332 server.stop(true);
34− clearDtsUrlCache();
3533 }
3634 });
3735
38−test("loadSourceDtsByUrlCached respects ttl expiration", async () => {
39− let requestCount = 0;
36+test("loadSourceDtsByUrl skips sources that fail to download", async () => {
4037 const server = Bun.serve({
4138 port: 0,
42− fetch: () => {
43− requestCount += 1;
44− return new Response("export interface operations { ping: {} }", {
39+ fetch: (request) => {
40+ const url = new URL(request.url);
41+ if (url.pathname === "/fail.d.ts") {
42+ return new Response("nope", { status: 500 });
43+ }
44+ return new Response("export interface operations { pong: {} }", {
4545 headers: { "content-type": "text/plain" },
4646 });
4747 },
4848 });
4949
5050 try {
51− clearDtsUrlCache();
52− const url = `http://127.0.0.1:${server.port}/types.d.ts`;
51+ const okUrl = `http://127.0.0.1:${server.port}/ok.d.ts`;
52+ const failUrl = `http://127.0.0.1:${server.port}/fail.d.ts`;
5353
54− await loadSourceDtsByUrlCached({ "openapi:one": url }, { ttlMs: 5 });
55− await Bun.sleep(20);
56− await loadSourceDtsByUrlCached({ "openapi:one": url }, { ttlMs: 5 });
54+ const loaded = await loadSourceDtsByUrl({
55+ "openapi:ok": okUrl,
56+ "openapi:fail": failUrl,
57+ });
5758
58− expect(requestCount).toBe(2);
59+ expect(loaded["openapi:ok"]).toContain("interface operations");
60+ expect(loaded["openapi:fail"]).toBeUndefined();
5961 } finally {
6062 server.stop(true);
61− clearDtsUrlCache();
6263 }
6364 });
executor/packages/core/src/dts-loader.ts+2−32
@@ -1,12 +1,3 @@
1−const DEFAULT_DTS_CACHE_TTL_MS = 5 * 60_000;
2−
3−interface DtsCacheEntry {
4− expiresAt: number;
5− promise: Promise<string | null>;
6−}
7−
8−const dtsUrlCache = new Map<string, DtsCacheEntry>();
9−
101 async function fetchDtsText(url: string): Promise<string | null> {
112 try {
123 const response = await fetch(url);
@@ -23,34 +14,17 @@ async function fetchDtsText(url: string): Promise<string | null> { · 10 unmodified lines
2314 }
2415 }
2516
26−function getCachedDtsPromise(url: string, ttlMs: number): Promise<string | null> {
27− const now = Date.now();
28− const cached = dtsUrlCache.get(url);
29− if (cached && cached.expiresAt > now) {
30− return cached.promise;
31− }
32−
33− const promise = fetchDtsText(url).catch(() => null);
34− dtsUrlCache.set(url, {
35− expiresAt: now + Math.max(1, ttlMs),
36− promise,
37− });
38− return promise;
39−}
40−
41−export async function loadSourceDtsByUrlCached(
17+export async function loadSourceDtsByUrl(
4218 dtsUrls: Record<string, string>,
43− options?: { ttlMs?: number },
4419 ): Promise<Record<string, string>> {
4520 const entries = Object.entries(dtsUrls);
4621 if (entries.length === 0) {
4722 return {};
4823 }
4924
50− const ttlMs = options?.ttlMs ?? DEFAULT_DTS_CACHE_TTL_MS;
5125 const results = await Promise.all(entries.map(async ([sourceKey, url]) => {
5226 if (!url) return [sourceKey, null] as const;
53− return [sourceKey, await getCachedDtsPromise(url, ttlMs)] as const;
27+ return [sourceKey, await fetchDtsText(url)] as const;
5428 }));
5529
5630 const sourceDtsBySource: Record<string, string> = {};
@@ -61,7 +35,3 @@ export async function loadSourceDtsByUrlCached( · 4 unmodified lines
6135 }
6236 return sourceDtsBySource;
6337 }
64−
65−export 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
2424 workspaceMembership: WorkspaceAccessMembership;
2525 };
2626
27−export function slugify(input: string): string {
27+export function slugify(input: string, fallback = "team"): string {
2828 const slug = input
2929 .toLowerCase()
3030 .replace(/[^a-z0-9]+/g, "-")
3131 .replace(/^-+|-+$/g, "");
32− return slug.length > 0 ? slug : "team";
32+ return slug.length > 0 ? slug : fallback;
3333 }
3434
3535 export 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
44 import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
55 import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
66 import type { OAuthClientMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
7−import { AnonymousOAuthServer, OAuthBadRequest } from "./anonymous-oauth";
7+import { AnonymousOAuthServer, InMemoryOAuthStorage, OAuthBadRequest } from "./anonymous-oauth";
88 import { handleMcpRequest, type McpWorkspaceContext } from "./mcp-server";
99 import type { AnonymousContext, CreateTaskInput, TaskRecord, ToolDescriptor } from "./types";
1010 import type { LiveTaskEvent } from "./events";
@@ -55,8 +55,7 @@ class FakeMcpService { · 44 unmodified lines
5555 completedAt: current.createdAt + 2,
5656 updatedAt: current.createdAt + 2,
5757 exitCode: 0,
58− stdout: `ran:${input.code.slice(0, 20)}`,
59− stderr: "",
58+ result: { ran: input.code.slice(0, 20) },
6059 });
6160 for (const listener of this.listeners.get(id) ?? []) {
6261 listener({ id: 1, eventName: "task", payload: { status: "completed" }, createdAt: Date.now() });
@@ -297,7 +296,10 @@ beforeAll(async () => { · 234 unmodified lines
297296 });
298297
299298 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+ });
301303 await oauthServer.init();
302304 });
303305
executor/packages/core/src/mcp-gateway-oauth.test.ts+9−3
@@ -19,7 +19,7 @@ import { computeS256Challenge } from "./anonymous-oauth"; · 18 unmodified lines
1919 */
2020
2121 // We import the gateway init helpers directly to avoid needing CONVEX_URL
22−import { AnonymousOAuthServer, OAuthBadRequest } from "./anonymous-oauth";
22+import { AnonymousOAuthServer, InMemoryOAuthStorage, OAuthBadRequest } from "./anonymous-oauth";
2323
2424 let server: ReturnType<typeof Bun.serve>;
2525 let baseUrl: string;
@@ -44,7 +44,10 @@ function generateCodeVerifier(): string { · 18 unmodified lines
4444 beforeAll(async () => {
4545 // We start our own server so we don't need CONVEX_URL.
4646 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+ });
4851 await oauthServer.init();
4952
5053 server = Bun.serve({
@@ -172,7 +175,10 @@ beforeAll(async () => { · 121 unmodified lines
172175
173176 // Update the oauth server's issuer to match the real URL
174177 // 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+ });
176182 await oauthServer.init();
177183 });
178184
executor/packages/core/src/mcp-server.test.ts+9−14
@@ -44,10 +44,9 @@ class FakeMcpService { · 43 unmodified lines
4444 completedAt: current.createdAt + 2,
4545 updatedAt: current.createdAt + 2,
4646 exitCode: 0,
47− stdout: input.metadata?.largeStdout === true
48− ? `header\n${"x".repeat(35_000)}`
49− : `ran:${input.code.slice(0, 20)}`,
50− stderr: "",
47+ result: input.metadata?.largeResult === true
48+ ? { data: `header\n${"x".repeat(35_000)}` }
49+ : { ran: input.code.slice(0, 20) },
5150 });
5251 // Notify subscribers
5352 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
164163 expect(text?.type).toBe("text");
165164 if (text?.type === "text") {
166165 expect(text.text).toContain("status: completed");
167− expect(text.text).toContain("ran:console.log('hello f");
166+ expect(text.text).toContain('"ran": "console.log');
168167 }
169168 });
170169 });
171170
172−test("run_code MCP tool warns and previews large output", async () => {
171+test("run_code MCP tool previews large returned result", async () => {
173172 const service = new FakeMcpService();
174173
175174 await withMcpClient(service, async (client) => {
@@ -177,7 +176,7 @@ test("run_code MCP tool warns and previews large output", async () => { · 1 unmodified line
177176 name: "run_code",
178177 arguments: {
179178 code: "console.log('large')",
180− metadata: { largeStdout: true },
179+ metadata: { largeResult: true },
181180 },
182181 })) as {
183182 isError?: boolean;
@@ -189,15 +188,11 @@ test("run_code MCP tool warns and previews large output", async () => { · 5 unmodified lines
189188 const text = result.content.find((part) => part.type === "text");
190189 expect(text?.type).toBe("text");
191190 if (text?.type === "text") {
192− expect(text.text).toContain("outputWarning: Large runtime output detected");
193− expect(text.text).toContain("outputHint: Return compact summaries");
194− expect(text.text).toContain("[stdout preview truncated");
191+ expect(text.text).toContain("[result preview truncated");
195192 }
196193
197− const fullStdout = typeof result.structuredContent?.stdout === "string"
198− ? result.structuredContent.stdout
199− : "";
200− expect(fullStdout.length).toBeGreaterThan(30_000);
194+ const fullResult = result.structuredContent?.result as { data?: string } | undefined;
195+ expect((fullResult?.data ?? "").length).toBeGreaterThan(30_000);
201196 });
202197 });
203198
executor/packages/core/src/mcp-server.ts+20−118
@@ -1,7 +1,8 @@
11 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22 import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3+import { Result } from "better-result";
34 import { z } from "zod";
4−import { loadSourceDtsByUrlCached } from "./dts-loader";
5+import { loadSourceDtsByUrl } from "./dts-loader";
56 import { generateToolDeclarations, generateToolInventory, typecheckCode } from "./typechecker";
67 import type { LiveTaskEvent } from "./events";
78 import type {
@@ -94,7 +95,7 @@ function listTopLevelToolKeys(tools: ToolDescriptor[]): string[] { · 86 unmodified lines
9495 }
9596
9697 function summarizeTask(task: TaskRecord): string {
97− const maxStreamPreviewChars = 30_000;
98+ const maxResultPreviewChars = 30_000;
9899 const lines = [
99100 `taskId: ${task.id}`,
100101 `status: ${task.status}`,
@@ -109,29 +110,17 @@ function summarizeTask(task: TaskRecord): string { · 8 unmodified lines
109110 lines.push(`error: ${task.error}`);
110111 }
111112
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−
123113 let text = lines.join("\n");
124− if (task.stdout && task.stdout.trim()) {
125− const stdoutPreview = task.stdout.length > maxStreamPreviewChars
126− ? `${task.stdout.slice(0, maxStreamPreviewChars)}\n... [stdout preview truncated ${task.stdout.length - maxStreamPreviewChars} chars]`
127− : task.stdout;
128− text += asCodeBlock("text", stdoutPreview);
129− }
130− if (task.stderr && task.stderr.trim()) {
131− const stderrPreview = task.stderr.length > maxStreamPreviewChars
132− ? `${task.stderr.slice(0, maxStreamPreviewChars)}\n... [stderr preview truncated ${task.stderr.length - maxStreamPreviewChars} chars]`
133− : task.stderr;
134− text += asCodeBlock("text", stderrPreview);
114+ if (task.result !== undefined) {
115+ const serialized = Result.try(() => JSON.stringify(task.result, null, 2)).unwrapOr(String(task.result));
116+ if (serialized.length > maxResultPreviewChars) {
117+ text += asCodeBlock(
118+ "json",
119+ `${serialized.slice(0, maxResultPreviewChars)}\n... [result preview truncated ${serialized.length - maxResultPreviewChars} chars]`,
120+ );
121+ } else {
122+ text += asCodeBlock("json", serialized);
123+ }
135124 }
136125 return text;
137126 }
@@ -260,7 +249,7 @@ function waitForTerminalTask( · 122 unmodified lines
260249
261250 function buildRunCodeDescription(tools?: ToolDescriptor[]): string {
262251 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.";
264253 const toolList = tools ?? [];
265254 const topLevelKeys = listTopLevelToolKeys(toolList);
266255 const rootKeysNote = topLevelKeys.length > 0
@@ -428,7 +417,7 @@ function createRunCodeTool( · 161 unmodified lines
428417 clientId: context.clientId,
429418 });
430419 toolsForContext = tools;
431− sourceDtsBySource = await loadSourceDtsByUrlCached(dtsUrls ?? {});
420+ sourceDtsBySource = await loadSourceDtsByUrl(dtsUrls ?? {});
432421 } else {
433422 toolsForContext = await service.listTools({
434423 workspaceId: context.workspaceId,
@@ -537,8 +526,7 @@ function createRunCodeTool( · 102 unmodified lines
537526 runtimeId: task.runtimeId,
538527 exitCode: task.exitCode,
539528 error: task.error,
540− stdout: task.stdout,
541− stderr: task.stderr,
529+ result: task.result,
542530 workspaceId: context.workspaceId,
543531 actorId: context.actorId,
544532 sessionId: context.sessionId,
@@ -606,36 +594,6 @@ async function createMcpServer( · 61 unmodified lines
606594 return mcp;
607595 }
608596
609−function createDelegatingService(ref: { current: McpExecutorService }): McpExecutorService {
610− return {
611− createTask: async (input) => await ref.current.createTask(input),
612− getTask: async (taskId, workspaceId) => await ref.current.getTask(taskId, workspaceId),
613− subscribe: (taskId, listener) => ref.current.subscribe(taskId, listener),
614− bootstrapAnonymousContext: async (sessionId) => await ref.current.bootstrapAnonymousContext(sessionId),
615− listTools: async (context) => await ref.current.listTools(context),
616− listToolsForTypecheck: ref.current.listToolsForTypecheck
617− ? async (context) => await ref.current.listToolsForTypecheck!(context)
618− : undefined,
619− typecheckRunCode: ref.current.typecheckRunCode
620− ? async (input) => await ref.current.typecheckRunCode!(input)
621− : undefined,
622− listPendingApprovals: ref.current.listPendingApprovals
623− ? async (workspaceId) => await ref.current.listPendingApprovals!(workspaceId)
624− : undefined,
625− resolveApproval: ref.current.resolveApproval
626− ? async (input) => await ref.current.resolveApproval!(input)
627− : undefined,
628− };
629−}
630−
631−type McpSessionRuntime = {
632− transport: WebStandardStreamableHTTPServerTransport;
633− mcp: McpServer;
634− serviceRef: { current: McpExecutorService };
635−};
636−
637−const mcpSessionRuntimes: Record<string, McpSessionRuntime> = Object.create(null);
638−
639597 async function handleStatelessMcpRequest(
640598 service: McpExecutorService,
641599 request: Request,
@@ -665,64 +623,8 @@ export async function handleMcpRequest( · 23 unmodified lines
665623 request: Request,
666624 context?: McpWorkspaceContext,
667625 ): Promise<Response> {
668− const runtimeSessionId = request.headers.get("mcp-session-id") ?? undefined;
669−
670− if (runtimeSessionId) {
671− const runtime = mcpSessionRuntimes[runtimeSessionId];
672− if (runtime) {
673− runtime.serviceRef.current = service;
674− return await runtime.transport.handleRequest(request);
675− }
676−
677− const headers = new Headers(request.headers);
678− headers.delete("mcp-session-id");
679− const requestWithoutSession = new Request(request, { headers });
680− return await handleStatelessMcpRequest(service, requestWithoutSession, context);
681− }
682−
683− const serviceRef = { current: service };
684− const delegatingService = createDelegatingService(serviceRef);
685− const mcp = await createMcpServer(delegatingService, context);
686−
687− const transport = new WebStandardStreamableHTTPServerTransport({
688− sessionIdGenerator: () => crypto.randomUUID(),
689− enableJsonResponse: true,
690− onsessioninitialized: (sessionId) => {
691− mcpSessionRuntimes[sessionId] = {
692− transport,
693− mcp,
694− serviceRef,
695− };
696− },
697− onsessionclosed: async (sessionId) => {
698− if (!sessionId) {
699− return;
700− }
701−
702− const runtime = mcpSessionRuntimes[sessionId];
703− if (!runtime) {
704− return;
705− }
706−
707− delete mcpSessionRuntimes[sessionId];
708− await runtime.mcp.close().catch(() => {});
709− },
710− });
711−
712− try {
713− await mcp.connect(transport);
714− const response = await transport.handleRequest(request);
715−
716− const initializedSessionId = response.headers.get("mcp-session-id") ?? undefined;
717− if (!initializedSessionId) {
718− await transport.close().catch(() => {});
719− await mcp.close().catch(() => {});
720− }
721−
722− return response;
723− } catch (error) {
724− await transport.close().catch(() => {});
725− await mcp.close().catch(() => {});
726− throw error;
727− }
626+ const headers = new Headers(request.headers);
627+ headers.delete("mcp-session-id");
628+ const requestWithoutSession = new Request(request, { headers });
629+ return await handleStatelessMcpRequest(service, requestWithoutSession, context);
728630 }
executor/packages/core/src/openapi-cache.test.ts+93−0
@@ -345,6 +345,99 @@ describe("buildOpenApiToolsFromPrepared", () => { · 344 unmodified lines
345345 );
346346 });
347347
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+
348441 test("infers workspace auth from OpenAPI securitySchemes when source auth is unset", async () => {
349442 const spec: Record<string, unknown> = {
350443 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
2121 * `ToolBridge` entrypoint in the host Worker (passed via `env` bindings),
2222 * which in turn calls Convex callback RPCs to resolve tools.
2323 *
24− * 4. Console output from the isolate is similarly relayed back via callback
25− * RPC mutation.
24+ * 4. Only explicit `return` values from the isolate are included in callback
25+ * completion payloads.
2626 *
2727 * 5. The host Worker accepts the run immediately and finishes execution
2828 * 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
44 import type { Id } from "../../../convex/_generated/dataModel";
55 import type {
66 ExecutionAdapter,
7− RuntimeOutputEvent,
87 SandboxExecutionRequest,
98 ToolDefinition,
109 ToolRunContext,
@@ -20,7 +19,6 @@ function request(code: string, timeoutMs = 1_000): SandboxExecutionRequest { · 9 unmodified lines
2019
2120 function createRuntimeAdapter(
2221 tools: Map<string, ToolDefinition>,
23− outputEvents: RuntimeOutputEvent[],
2422 ): ExecutionAdapter {
2523 return {
2624 async invokeTool(call) {
@@ -51,14 +49,10 @@ function createRuntimeAdapter( · 24 unmodified lines
5149 };
5250 }
5351 },
54− emitOutput(event) {
55− outputEvents.push(event);
56− },
5752 };
5853 }
5954
60−test("executes tool calls and captures output", async () => {
61− const outputEvents: RuntimeOutputEvent[] = [];
55+test("executes tool calls and captures returned result", async () => {
6256 const tools = new Map<string, ToolDefinition>([
6357 [
6458 "utils.echo",
@@ -74,16 +68,13 @@ test("executes tool calls and captures output", async () => { · 9 unmodified lines
7468 const result = await runCodeWithAdapter(
7569 request(`
7670 const out = await tools.utils.echo({ message: "hi" });
77− console.log("out", out.echoed.message);
78− return 42;
71+ return { message: out.echoed.message, total: 42 };
7972 `),
80− createRuntimeAdapter(tools, outputEvents),
73+ createRuntimeAdapter(tools),
8174 );
8275
8376 expect(result.status).toBe("completed");
84− expect(result.stdout).toContain("out hi");
85− expect(result.stdout).toContain("result: 42");
86− expect(outputEvents.some((event) => event.stream === "stdout")).toBe(true);
77+ expect(result.result).toEqual({ message: "hi", total: 42 });
8778 });
8879
8980 test("returns denied when adapter marks tool call denied", async () => {
@@ -95,7 +86,6 @@ test("returns denied when adapter marks tool call denied", async () => { · 5 unmodified lines
9586 error: "policy denied",
9687 };
9788 },
98− emitOutput() {},
9989 };
10090
10191 const result = await runCodeWithAdapter(
@@ -107,7 +97,6 @@ test("returns denied when adapter marks tool call denied", async () => { · 5 unmodified lines
10797
10898 expect(result.status).toBe("denied");
10999 expect(result.error).toBe("policy denied");
110− expect(result.stderr).toContain("policy denied");
111100 });
112101
113102 test("times out long-running code", async () => {
@@ -115,7 +104,6 @@ test("times out long-running code", async () => { · 1 unmodified line
115104 async invokeTool() {
116105 return { ok: true, value: null };
117106 },
118− emitOutput() {},
119107 };
120108
121109 const result = await runCodeWithAdapter(
@@ -133,8 +121,7 @@ test("times out long-running code", async () => { · 11 unmodified lines
133121 });
134122
135123 test("sandboxed runner does not expose host globals", async () => {
136− const outputEvents: RuntimeOutputEvent[] = [];
137− const adapter = createRuntimeAdapter(new Map(), outputEvents);
124+ const adapter = createRuntimeAdapter(new Map());
138125
139126 const result = await runCodeWithAdapter(
140127 request(`
@@ -151,27 +138,28 @@ test("sandboxed runner does not expose host globals", async () => { · 10 unmodified lines
151138 fetch: typeof fetch,
152139 fsEscape,
153140 };
154− console.log(JSON.stringify(checks));
155141 if (checks.process !== "undefined") throw new Error("process leaked");
156142 if (checks.bun !== "undefined") throw new Error("Bun leaked");
157143 if (checks.fetch !== "undefined") throw new Error("fetch leaked");
158144 if (checks.fsEscape !== "undefined" && checks.fsEscape !== "blocked") {
159145 throw new Error("constructor escape leaked");
160146 }
161− return "ok";
147+ return checks;
162148 `),
163149 adapter,
164150 );
165151
166152 expect(result.status).toBe("completed");
167− expect(result.stdout).toContain("\"process\":\"undefined\"");
168− expect(result.stdout).toContain("\"bun\":\"undefined\"");
169− expect(result.stdout).toContain("\"fetch\":\"undefined\"");
170− expect(result.stdout).toMatch(/"fsEscape":"(undefined|blocked)"/);
153+ expect(result.result).toEqual({
154+ process: "undefined",
155+ bun: "undefined",
156+ fetch: "undefined",
157+ fsEscape: expect.stringMatching(/^(undefined|blocked)$/),
158+ });
171159 });
172160
173161 test("sandbox blocks global constructor and eval escapes", async () => {
174− const adapter = createRuntimeAdapter(new Map(), []);
162+ const adapter = createRuntimeAdapter(new Map());
175163
176164 const result = await runCodeWithAdapter(
177165 request(`
@@ -197,25 +185,27 @@ test("sandbox blocks global constructor and eval escapes", async () => { · 19 unmodified lines
197185 functionEscape = "blocked";
198186 }
199187
200− console.log(JSON.stringify({ ctorEscape, evalEscape, functionEscape }));
201188 if (ctorEscape !== "undefined" && ctorEscape !== "blocked") throw new Error("ctor escape");
202189 if (evalEscape !== "undefined" && evalEscape !== "blocked") throw new Error("eval escape");
203190 if (functionEscape !== "undefined" && functionEscape !== "blocked") throw new Error("Function escape");
191+ return { ctorEscape, evalEscape, functionEscape };
204192 `),
205193 adapter,
206194 );
207195
208196 expect(result.status).toBe("completed");
209− expect(result.stdout).toMatch(/"ctorEscape":"(undefined|blocked)"/);
210− expect(result.stdout).toMatch(/"evalEscape":"(undefined|blocked)"/);
211− expect(result.stdout).toMatch(/"functionEscape":"(undefined|blocked)"/);
197+ expect(result.result).toEqual({
198+ ctorEscape: expect.stringMatching(/^(undefined|blocked)$/),
199+ evalEscape: expect.stringMatching(/^(undefined|blocked)$/),
200+ functionEscape: expect.stringMatching(/^(undefined|blocked)$/),
201+ });
212202 });
213203
214204 test("sandbox prototype pollution does not leak to host", async () => {
215205 const marker = `__pwned_${crypto.randomUUID().replace(/-/g, "")}`;
216206 expect(({} as Record<string, unknown>)[marker]).toBeUndefined();
217207
218− const adapter = createRuntimeAdapter(new Map(), []);
208+ const adapter = createRuntimeAdapter(new Map());
219209 const result = await runCodeWithAdapter(
220210 request(`
221211 Object.prototype[${JSON.stringify(marker)}] = "yes";
@@ -359,8 +349,6 @@ test("runs openapi and graphql sourced tools through the runtime", async () => { · 137 unmodified lines
359349 ]);
360350
361351 const toolMap = new Map(tools.map((tool) => [tool.path, tool]));
362− const outputEvents: RuntimeOutputEvent[] = [];
363−
364352 const result = await runCodeWithAdapter(
365353 request(`
366354 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
369357 query: "mutation($value: Int!) { increment(value: $value) }",
370358 variables: { value: 3 },
371359 });
372− console.log("sum", sum.sum);
373− console.log("hello", hello.data);
374− console.log("inc", inc.data.increment);
375− return sum.sum + inc.data.increment;
360+ return {
361+ sum: sum.sum,
362+ hello: hello.data.hello,
363+ inc: inc.data.increment,
364+ total: sum.sum + inc.data.increment,
365+ };
376366 `),
377− createRuntimeAdapter(toolMap, outputEvents),
367+ createRuntimeAdapter(toolMap),
378368 );
379369
380370 expect(result.status).toBe("completed");
381− expect(result.stdout).toContain("sum 7");
382− expect(result.stdout).toContain("hello world");
383− expect(result.stdout).toContain("inc 4");
384− expect(result.stdout).toContain("result: 11");
385− expect(outputEvents.length).toBeGreaterThan(0);
371+ expect(result.result).toMatchObject({ sum: 7, inc: 4, total: 11 });
386372 } finally {
387373 server.stop(true);
388374 }
executor/packages/core/src/runtimes/runtime-core.ts+26−54
@@ -2,7 +2,7 @@ · 1 unmodified line
22
33 // NOTE: The Vercel sandbox runtime (vercel-sandbox-runtime.ts) contains a JS
44 // string version of similar logic, built from sandbox-fragments.ts. Changes to
5−// the core helpers here (formatArgs, createToolsProxy, console proxy, execution
5+// the core helpers here (createToolsProxy, console proxy, execution
66 // loop, result mapping) should be mirrored there.
77 import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution-constants";
88 import { Result } from "better-result";
@@ -14,23 +14,25 @@ import type { · 5 unmodified lines
1414 } from "../types";
1515 import { transpileForRuntime } from "./transpile";
1616
17−function formatArgs(args: unknown[]): string {
18− return args
19− .map((value) => {
20− if (typeof value === "string") return value;
21− return Result.try(() => JSON.stringify(value)).unwrapOr(String(value));
22− })
23− .join(" ");
17+async function sleep(ms: number): Promise<void> {
18+ await new Promise((resolve) => setTimeout(resolve, ms));
2419 }
2520
26−function fireAndForget(promise: void | Promise<void>): void {
27− if (promise && typeof promise === "object" && "then" in promise) {
28− void (promise as Promise<void>).catch(() => {});
21+function sanitizeExecutionResult(value: unknown): unknown {
22+ if (value === undefined) {
23+ return undefined;
2924 }
30−}
3125
32−async function sleep(ms: number): Promise<void> {
33− await new Promise((resolve) => setTimeout(resolve, ms));
26+ const serialized = Result.try(() => JSON.stringify(value));
27+ if (serialized.isErr()) {
28+ return String(value);
29+ }
30+
31+ if (serialized.value === undefined) {
32+ return null;
33+ }
34+
35+ return Result.try(() => JSON.parse(serialized.value) as unknown).unwrapOr(String(value));
3436 }
3537
3638 function createToolsProxy(
@@ -110,40 +112,13 @@ export async function runCodeWithAdapter( · 73 unmodified lines
110112 adapter: ExecutionAdapter,
111113 ): Promise<SandboxExecutionResult> {
112114 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− };
139115
140116 const mkResult = (
141117 status: SandboxExecutionResult["status"],
142− opts?: { error?: string; exitCode?: number },
118+ opts?: { error?: string; exitCode?: number; result?: unknown },
143119 ): SandboxExecutionResult => ({
144120 status,
145− stdout: stdoutLines.join("\n"),
146− stderr: stderrLines.join("\n"),
121+ result: opts?.result,
147122 exitCode: opts?.exitCode,
148123 error: opts?.error,
149124 durationMs: Date.now() - startedAt,
@@ -152,17 +127,16 @@ export async function runCodeWithAdapter( · 2 unmodified lines
152127 // ── Transpile ──────────────────────────────────────────────────────────
153128 const transpiled = transpileForRuntime(request.code);
154129 if (transpiled.isErr()) {
155− appendStderr(transpiled.error.message);
156130 return mkResult("failed", { error: transpiled.error.message });
157131 }
158132
159133 // ── Sandbox setup ──────────────────────────────────────────────────────
160134 const tools = createToolsProxy(adapter, request.taskId);
161135 const consoleProxy = {
162− log: (...args: unknown[]) => appendStdout(formatArgs(args)),
163− info: (...args: unknown[]) => appendStdout(formatArgs(args)),
164− warn: (...args: unknown[]) => appendStderr(formatArgs(args)),
165− error: (...args: unknown[]) => appendStderr(formatArgs(args)),
136+ log: (..._args: unknown[]) => {},
137+ info: (..._args: unknown[]) => {},
138+ warn: (..._args: unknown[]) => {},
139+ error: (..._args: unknown[]) => {},
166140 };
167141
168142 const sandbox = Object.assign(Object.create(null), {
@@ -206,13 +180,11 @@ export async function runCodeWithAdapter( · 37 unmodified lines
206180 execution.error.cause,
207181 request,
208182 );
209− appendStderr(message);
210183 return mkResult(status, { error: message });
211184 }
212185
213− if (execution.value !== undefined) {
214− appendStdout(`result: ${formatArgs([execution.value])}`);
215− }
216−
217− return mkResult("completed", { exitCode: 0 });
186+ return mkResult("completed", {
187+ exitCode: 0,
188+ result: sanitizeExecutionResult(execution.value),
189+ });
218190 }
executor/packages/core/src/runtimes/transpile.ts+2−7
@@ -14,14 +14,9 @@ export class TranspileError extends TaggedError("TranspileError")<{ · 13 unmodified lines
1414 // TypeScript module loader
1515 // ---------------------------------------------------------------------------
1616
17−let cachedTypeScript: typeof import("typescript") | null | undefined;
18−
1917 function getTypeScriptModule(): typeof import("typescript") | null {
20− if (cachedTypeScript === undefined) {
21− const loaded = Result.try(() => require("typescript") as typeof import("typescript"));
22− cachedTypeScript = loaded.isOk() ? loaded.value : null;
23− }
24− return cachedTypeScript ?? null;
18+ const loaded = Result.try(() => require("typescript") as typeof import("typescript"));
19+ return loaded.isOk() ? loaded.value : null;
2520 }
2621
2722 // ---------------------------------------------------------------------------
executor/packages/core/src/tool-discovery.ts+5−2
@@ -1,5 +1,6 @@
11 import type { ToolDefinition } from "./types";
22 import {
3+ compactArgKeysHint,
34 compactArgTypeHint,
45 compactDescriptionLine,
56 compactReturnTypeHint,
@@ -74,7 +75,7 @@ function buildExampleCall(entry: DiscoverIndexEntry): string { · 68 unmodified lines
7475
7576 const keys = entry.argPreviewKeys.length > 0 ? entry.argPreviewKeys : extractTopLevelTypeKeys(entry.argsType);
7677 if (keys.length > 0) {
77− const argsSnippet = keys.slice(0, 3)
78+ const argsSnippet = keys.slice(0, 5)
7879 .map((key) => `${key}: ${key.toLowerCase().includes("input") ? "{ /* ... */ }" : "..."}`)
7980 .join(", ");
8081 return `await tools.${entry.path}({ ${argsSnippet} });`;
@@ -214,7 +215,9 @@ function formatSignature(entry: DiscoverIndexEntry, depth: number, compact: bool · 133 unmodified lines
214215 return "(input: ...): Promise<...>";
215216 }
216217
217− const args = compactArgTypeHint(entry.argsType);
218+ const args = entry.argPreviewKeys.length > 0
219+ ? compactArgKeysHint(entry.argPreviewKeys)
220+ : compactArgTypeHint(entry.argsType);
218221 const returns = compactReturnTypeHint(entry.returnsType);
219222
220223 if (depth === 1) {
executor/packages/core/src/tool-sources.ts+114−41
@@ -526,6 +526,115 @@ function jsonSchemaTypeHintFallback( · 525 unmodified lines
526526 return "unknown";
527527 }
528528
529+type OpenApiParameterHint = {
530+ name: string;
531+ required: boolean;
532+ schema: Record<string, unknown>;
533+};
534+
535+function pushUnique(values: string[], seen: Set<string>, raw: string): void {
536+ const value = raw.trim();
537+ if (!value || seen.has(value)) return;
538+ seen.add(value);
539+ values.push(value);
540+}
541+
542+function collectTopLevelSchemaKeys(
543+ schema: unknown,
544+ componentSchemas?: Record<string, unknown>,
545+ seenRefs: Set<string> = new Set(),
546+): string[] {
547+ if (!schema || typeof schema !== "object") return [];
548+
549+ const record = schema as Record<string, unknown>;
550+ const ref = typeof record.$ref === "string" ? record.$ref : "";
551+ if (ref.startsWith("#/components/schemas/")) {
552+ if (seenRefs.has(ref)) return [];
553+ const key = ref.slice("#/components/schemas/".length);
554+ const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {};
555+ if (Object.keys(resolved).length === 0) return [];
556+ const nextSeen = new Set(seenRefs);
557+ nextSeen.add(ref);
558+ return collectTopLevelSchemaKeys(resolved, componentSchemas, nextSeen);
559+ }
560+
561+ const keys: string[] = [];
562+ const seen = new Set<string>();
563+
564+ for (const key of Object.keys(asRecord(record.properties))) {
565+ pushUnique(keys, seen, key);
566+ }
567+
568+ const combinators: unknown[] = [
569+ ...(Array.isArray(record.allOf) ? record.allOf : []),
570+ ...(Array.isArray(record.oneOf) ? record.oneOf : []),
571+ ...(Array.isArray(record.anyOf) ? record.anyOf : []),
572+ ];
573+
574+ for (const entry of combinators) {
575+ for (const key of collectTopLevelSchemaKeys(entry, componentSchemas, seenRefs)) {
576+ pushUnique(keys, seen, key);
577+ }
578+ }
579+
580+ return keys;
581+}
582+
583+function buildOpenApiInputSchema(
584+ parameters: OpenApiParameterHint[],
585+ requestBodySchema: Record<string, unknown>,
586+): JsonSchema {
587+ const hasBodySchema = Object.keys(requestBodySchema).length > 0;
588+ const hasParams = parameters.length > 0;
589+
590+ if (!hasBodySchema && !hasParams) {
591+ return {};
592+ }
593+
594+ const parameterSchema: JsonSchema = {
595+ type: "object",
596+ properties: Object.fromEntries(parameters.map((param) => [param.name, param.schema])),
597+ required: parameters.filter((param) => param.required).map((param) => param.name),
598+ };
599+
600+ if (!hasBodySchema) {
601+ return parameterSchema;
602+ }
603+
604+ if (!hasParams) {
605+ return requestBodySchema;
606+ }
607+
608+ return {
609+ allOf: [parameterSchema, requestBodySchema],
610+ };
611+}
612+
613+function buildOpenApiArgPreviewKeys(
614+ parameters: OpenApiParameterHint[],
615+ requestBodySchema: Record<string, unknown>,
616+ componentSchemas?: Record<string, unknown>,
617+): string[] {
618+ const keys: string[] = [];
619+ const seen = new Set<string>();
620+
621+ for (const parameter of parameters) {
622+ if (!parameter.required) continue;
623+ pushUnique(keys, seen, parameter.name);
624+ }
625+
626+ for (const key of collectTopLevelSchemaKeys(requestBodySchema, componentSchemas)) {
627+ pushUnique(keys, seen, key);
628+ }
629+
630+ for (const parameter of parameters) {
631+ if (parameter.required) continue;
632+ pushUnique(keys, seen, parameter.name);
633+ }
634+
635+ return keys;
636+}
637+
529638 async function connectMcp(
530639 url: string,
531640 queryParams: Record<string, string> | undefined,
@@ -908,7 +1017,7 @@ function compactOpenApiPaths( · 376 unmodified lines
9081017 return entry;
9091018 };
9101019
911− const normalizeParameters = (entries: unknown): Array<Record<string, unknown>> => {
1020+ const normalizeParameters = (entries: unknown): Array<OpenApiParameterHint & { in: string }> => {
9121021 if (!Array.isArray(entries)) return [];
9131022 return entries
9141023 .map((entry) => resolveParam(asRecord(entry)))
@@ -982,32 +1091,12 @@ function compactOpenApiPaths( · 67 unmodified lines
9821091 const mergedParameters = normalizeParameters(operation.parameters).concat(sharedParameters);
9831092 const hasInputSchema =
9841093 mergedParameters.length > 0 || Object.keys(requestBodySchema).length > 0;
985− const combinedSchema: JsonSchema = {
986− type: "object",
987− properties: {
988− ...Object.fromEntries(
989− mergedParameters
990− .map((param) => [param.name, param.schema]),
991− ),
992− ...asRecord(requestBodySchema.properties),
993− },
994− required: [
995− ...mergedParameters
996− .filter((param) => param.required)
997− .map((param) => param.name as string),
998− ...((Array.isArray(requestBodySchema.required)
999− ? requestBodySchema.required.filter((item): item is string => typeof item === "string")
1000− : []) as string[]),
1001− ],
1002− };
1094+ const combinedSchema = buildOpenApiInputSchema(mergedParameters, requestBodySchema);
10031095 compactOperation._argsTypeHint = hasInputSchema
10041096 ? jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas)
10051097 : "{}";
10061098 compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas);
1007− const previewKeys = [
1008− ...mergedParameters.map((param) => String(param.name ?? "")).filter((name) => name.length > 0),
1009− ...Object.keys(asRecord(requestBodySchema.properties)),
1010− ];
1099+ const previewKeys = buildOpenApiArgPreviewKeys(mergedParameters, requestBodySchema, compSchemas);
10111100 if (previewKeys.length > 0) {
10121101 compactOperation._argPreviewKeys = [...new Set(previewKeys)];
10131102 }
@@ -1254,29 +1343,13 @@ export function buildOpenApiToolsFromPrepared( · 240 unmodified lines
12541343 if (Object.keys(responseSchema).length > 0) break;
12551344 }
12561345
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−
12711346 const hasInputSchema = parameters.length > 0 || Object.keys(requestBodySchema).length > 0;
1347+ const combinedSchema = buildOpenApiInputSchema(parameters, requestBodySchema);
12721348
12731349 argsType = hasInputSchema ? jsonSchemaTypeHintFallback(combinedSchema) : "{}";
12741350 returnsType = responseTypeHintFromSchema(responseSchema, responseStatus);
12751351 if (argPreviewKeys.length === 0) {
1276− argPreviewKeys = [
1277− ...parameters.map((param) => param.name),
1278− ...Object.keys(asRecord(requestBodySchema.properties)),
1279− ].filter((name, index, all) => name.length > 0 && all.indexOf(name) === index);
1352+ argPreviewKeys = buildOpenApiArgPreviewKeys(parameters, requestBodySchema);
12801353 }
12811354 }
12821355
executor/packages/core/src/typechecker.test.ts+48−1
@@ -307,6 +307,54 @@ export interface operations { · 306 unmodified lines
307307 expect(bad.errors.length).toBeGreaterThan(0);
308308 });
309309
310+ test("OpenAPI declarations intersect strict fallback args with indexed-access inputs", () => {
311+ const tools: ToolDescriptor[] = [
312+ {
313+ path: "vercel.dns.create_record",
314+ description: "Create DNS record",
315+ approval: "auto",
316+ source: "openapi:vercel",
317+ operationId: "createRecord",
318+ strictArgsType: "{ domain: string; type: \"CNAME\" | \"TXT\"; name: string; value: string }",
319+ },
320+ ];
321+
322+ const sourceDts = `
323+export interface operations {
324+ createRecord: {
325+ parameters: { path: { domain: string } };
326+ requestBody: {
327+ content: {
328+ "application/json":
329+ | { type: "A" | "CNAME" | "TXT" }
330+ | { type: "CNAME"; name: string; value: string };
331+ };
332+ };
333+ responses: { 200: { content: { "application/json": { id: string } } } };
334+ };
335+}
336+`;
337+
338+ const declarations = generateToolDeclarations(tools, {
339+ sourceDtsBySource: {
340+ "openapi:vercel": sourceDts,
341+ },
342+ });
343+
344+ const ok = typecheckCode(
345+ 'await tools.vercel.dns.create_record({ domain: "executor.sh", type: "CNAME", name: "api", value: "convex.domains" });',
346+ declarations,
347+ );
348+ expect(ok.ok).toBe(true);
349+
350+ const missing = typecheckCode(
351+ 'await tools.vercel.dns.create_record({ domain: "executor.sh", type: "CNAME" });',
352+ declarations,
353+ );
354+ expect(missing.ok).toBe(false);
355+ expect(missing.errors.some((error) => error.includes("name") || error.includes("value"))).toBe(true);
356+ });
357+
310358 test("OpenAPI helper types infer vendor JSON response payloads", () => {
311359 const tools: ToolDescriptor[] = [
312360 {
@@ -464,4 +512,3 @@ export interface operations { · 151 unmodified lines
464512 expect(result.errors).toHaveLength(0);
465513 });
466514 });
467−
executor/packages/core/src/typechecker.ts+8−8
@@ -20,14 +20,9 @@ import type { ToolDescriptor } from "./types"; · 19 unmodified lines
2020 // Tool declarations generation
2121 // ---------------------------------------------------------------------------
2222
23−let cachedTypeScript: typeof import("typescript") | null | undefined;
24−
2523 function getTypeScriptModule(): typeof import("typescript") | null {
26− if (cachedTypeScript === undefined) {
27− const loaded = Result.try(() => require("typescript") as typeof import("typescript"));
28− cachedTypeScript = loaded.isOk() ? loaded.value : null;
29− }
30− return cachedTypeScript ?? null;
24+ const loaded = Result.try(() => require("typescript") as typeof import("typescript"));
25+ return loaded.isOk() ? loaded.value : null;
3126 }
3227
3328 function isValidTypeExpression(typeExpression: string): boolean {
@@ -175,7 +170,12 @@ export function generateToolDeclarations( · 141 unmodified lines
175170 const tool = child.tool;
176171 if (tool.operationId && tool.source && dtsSources.has(tool.source)) {
177172 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}]>>;`);
179179 } else {
180180 const strictArgsType = tool.strictArgsType?.trim();
181181 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
77 export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied";
88 export type PolicyDecision = "allow" | "require_approval" | "deny";
99 export type CredentialScope = "workspace" | "actor";
10−export type CredentialProvider = "managed" | "workos-vault";
10+export type CredentialProvider = "local-convex" | "workos-vault";
1111 export type ToolApprovalMode = "auto" | "required";
1212 export type ToolSourceType = "mcp" | "openapi" | "graphql";
1313
14+export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
15+
16+export interface SourceAuthProfile {
17+ type: SourceAuthType;
18+ mode?: CredentialScope;
19+ header?: string;
20+ inferred: boolean;
21+}
22+
1423 export interface TaskRecord {
1524 id: string;
1625 code: string;
@@ -26,8 +35,7 @@ export interface TaskRecord { · 9 unmodified lines
2635 startedAt?: number;
2736 completedAt?: number;
2837 error?: string;
29− stdout?: string;
30− stderr?: string;
38+ result?: unknown;
3139 exitCode?: number;
3240 }
3341
@@ -61,10 +69,8 @@ export interface ToolCallRecord { · 27 unmodified lines
6169 callId: string;
6270 workspaceId: Id<"workspaces">;
6371 toolPath: string;
64− input: unknown;
6572 status: ToolCallStatus;
6673 approvalId?: string;
67− output?: unknown;
6874 error?: string;
6975 createdAt: number;
7076 updatedAt: number;
@@ -85,10 +91,13 @@ export interface AccessPolicyRecord { · 14 unmodified lines
8591
8692 export interface CredentialRecord {
8793 id: string;
94+ bindingId?: string;
8895 workspaceId: Id<"workspaces">;
8996 sourceKey: string;
9097 scope: CredentialScope;
9198 actorId?: string;
99+ overridesJson?: Record<string, unknown>;
100+ boundAuthFingerprint?: string;
92101 provider: CredentialProvider;
93102 secretJson: Record<string, unknown>;
94103 createdAt: number;
@@ -167,8 +176,7 @@ export interface SandboxExecutionRequest { · 72 unmodified lines
167176
168177 export interface SandboxExecutionResult {
169178 status: Extract<TaskStatus, "completed" | "failed" | "timed_out" | "denied">;
170− stdout: string;
171− stderr: string;
179+ result?: unknown;
172180 exitCode?: number;
173181 error?: string;
174182 durationMs: number;
@@ -193,18 +201,8 @@ export type ToolCallResult = · 18 unmodified lines
193201 | { ok: false; kind: "denied"; error: string }
194202 | { ok: false; kind: "failed"; error: string };
195203
196−export type RuntimeOutputStream = "stdout" | "stderr";
197−
198−export interface RuntimeOutputEvent {
199− runId: string;
200− stream: RuntimeOutputStream;
201− line: string;
202− timestamp: number;
203−}
204−
205204 export interface ExecutionAdapter {
206205 invokeTool(call: ToolCallRequest): Promise<ToolCallResult>;
207− emitOutput(event: RuntimeOutputEvent): void | Promise<void>;
208206 }
209207
210208 export interface SandboxRuntime {
executor/packages/runner-sandbox-host/src/index.ts+4−26
@@ -17,9 +17,8 @@ · 16 unmodified lines
1717 * (passed as a loopback service binding via `ctx.exports`) which invokes
1818 * Convex callback RPC functions to resolve them.
1919 *
20− * 4. Console output is buffered in the harness and returned in the response.
21− * Output lines are also streamed back to Convex in real-time via the
22− * ToolBridge binding.
20+ * 4. Console output is intentionally discarded. Only explicit `return` values
21+ * are included in terminal run results.
2322 *
2423 * 5. `/v1/runs` returns an accepted dispatch response immediately. Terminal
2524 * result status is reported back to Convex through callback RPC.
@@ -89,8 +88,7 @@ interface RunRequest { · 63 unmodified lines
8988
9089 interface RunResult {
9190 status: "completed" | "failed" | "timed_out" | "denied";
92− stdout: string;
93− stderr: string;
91+ result?: unknown;
9492 error?: string;
9593 exitCode?: number;
9694 }
@@ -143,8 +141,6 @@ function timingSafeEqual(a: string, b: string): boolean { · 46 unmodified lines
143141
144142 const failedResult = (error: string): RunResult => ({
145143 status: "failed",
146− stdout: "",
147− stderr: "",
148144 error,
149145 });
150146
@@ -198,21 +194,6 @@ export class ToolBridge extends WorkerEntrypoint<Env> { · 47 unmodified lines
198194
199195 return response.value as ToolCallResult;
200196 }
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− }
216197 }
217198
218199 // ── Sandbox Harness ──────────────────────────────────────────────────────────
@@ -285,8 +266,6 @@ async function executeSandboxRun(request: RunRequest, ctx: ExecutionContext, env · 66 unmodified lines
285266 if (cause instanceof DOMException && cause.name === "AbortError") {
286267 return {
287268 status: "timed_out",
288− stdout: "",
289− stderr: "",
290269 error: `Execution timed out after ${timeoutMs}ms`,
291270 };
292271 }
@@ -312,8 +291,7 @@ async function reportRunCompletion(request: RunRequest, result: RunResult, durat · 19 unmodified lines
312291 internalSecret: request.callback.internalSecret,
313292 runId: request.taskId,
314293 status: result.status,
315− stdout: result.stdout,
316− stderr: result.stderr,
294+ result: result.result,
317295 exitCode: result.exitCode,
318296 error: result.error,
319297 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
33
44 const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:";
55
6−function formatArgs(args) {
7− return args
8− .map((v) => {
9− if (typeof v === "string") return v;
10− try {
11− return JSON.stringify(v);
12− } catch {
13− return String(v);
14− }
15− })
16− .join(" ");
17−}
18−
196 async function sleep(ms) {
207 await new Promise((resolve) => setTimeout(resolve, ms));
218 }
@@ -48,39 +35,33 @@ function createToolsProxy(bridge, path = []) { · 26 unmodified lines
4835 });
4936 }
5037
51−export default {
52− async fetch(req, env, ctx) {
53− const stdoutLines = [];
54− const stderrLines = [];
55−
56− const appendStdout = (line) => {
57− stdoutLines.push(line);
58− ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stdout", line));
59− };
60− const appendStderr = (line) => {
61− stderrLines.push(line);
62− ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stderr", line));
63− };
38+function sanitizeExecutionResult(value) {
39+ if (value === undefined) return undefined;
40+ try {
41+ const serialized = JSON.stringify(value);
42+ if (serialized === undefined) return null;
43+ return JSON.parse(serialized);
44+ } catch {
45+ return String(value);
46+ }
47+}
6448
49+export default {
50+ async fetch(req, env, _ctx) {
6551 const tools = createToolsProxy(env.TOOL_BRIDGE);
6652 const console = {
67− log: (...args) => appendStdout(formatArgs(args)),
68− info: (...args) => appendStdout(formatArgs(args)),
69− warn: (...args) => appendStderr(formatArgs(args)),
70− error: (...args) => appendStderr(formatArgs(args)),
53+ log: (..._args) => {},
54+ info: (..._args) => {},
55+ warn: (..._args) => {},
56+ error: (..._args) => {},
7157 };
7258
7359 try {
7460 const value = await run(tools, console);
7561
76− if (value !== undefined) {
77− appendStdout("result: " + formatArgs([value]));
78− }
79−
8062 return _ResponseJson({
8163 status: "completed",
82− stdout: stdoutLines.join("\n"),
83− stderr: stderrLines.join("\n"),
64+ result: sanitizeExecutionResult(value),
8465 exitCode: 0,
8566 });
8667 } catch (error) {
@@ -88,19 +69,13 @@ export default { · 1 unmodified line
8869 error instanceof Error ? error.message : String(error);
8970 if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
9071 const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim();
91− appendStderr(denied);
9272 return _ResponseJson({
9373 status: "denied",
94− stdout: stdoutLines.join("\n"),
95− stderr: stderrLines.join("\n"),
9674 error: denied,
9775 });
9876 }
99− appendStderr(message);
10077 return _ResponseJson({
10178 status: "failed",
102− stdout: stdoutLines.join("\n"),
103− stderr: stderrLines.join("\n"),
10479 error: message,
10580 });
10681 }