History

Enhance executor functionality with web UI support and improved command handling

- Added support for running a packaged web UI with the new `executor web` command (default port: 5312). - Updated the `executor doctor` command to check the status of the web UI and its dependencies. - Enhanced installation script to include a web bundle installation process. - Introduced new environment variable `EXECUTOR_WEB_INSTALL_DIR` for web bundle installation path. - Refactored various functions to improve error handling and streamline the command execution process. - Updated README and documentation to reflect new commands and usage instructions.

Rhys Sullivan committed 9 months ago

Commit bce94cb1 parent c28dabb19 files · +1177 −98Browse files

Unified · Split

19 files
  1. .env.example +1 −0
  2. executor/README.md +6 −0
  3. executor/apps/web/next.config.ts +2 −1
  4. executor/convex/_generated/api.d.ts +37 −0
  5. executor/convex/anonymousOauth.ts +85 −0
  6. executor/convex/auth.ts +3 −2
  7. executor/convex/convex.config.ts +7 −1
  8. executor/executor.ts +46 −2
  9. executor/install +80 −2
  10. executor/lib/anonymous-oauth.test.ts +180 −38
  11. executor/lib/anonymous-oauth.ts +56 −20
  12. executor/lib/convex-oauth-storage.ts +100 −0
  13. executor/lib/managed_runtime.ts +392 −15
  14. executor/lib/mcp-gateway-oauth-sdk.test.ts +21 −10
  15. executor/lib/mcp-gateway-oauth.test.ts +4 −2
  16. executor/mcp-gateway.ts +11 −5
  17. executor/package.json +1 −0
  18. executor/scripts/build-release.ts +77 −0
  19. executor/uninstall +68 −0
.env.example+1−0
@@ -24,6 +24,7 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 23 unmodified lines
2424 # ── Local port overrides (optional) ──
2525 # EXECUTOR_BACKEND_PORT=5410
2626 # EXECUTOR_BACKEND_SITE_PORT=5411
27+# EXECUTOR_WEB_PORT=5312
2728 # EXECUTOR_MCP_GATEWAY_PORT=5313
2829 # NEXT_PUBLIC_LOCAL_MCP_ORIGIN=http://localhost:4313
2930
executor/README.md+6−0
@@ -11,6 +11,7 @@ curl -fsSL https://executor.sh/install | bash · 10 unmodified lines
1111 The installed `executor` binary bootstraps and manages its own runtime under `~/.executor/runtime`, including:
1212
1313 - `convex-local-backend` (downloaded from Convex precompiled releases)
14+- packaged Executor web UI bundle
1415 - persisted local backend config (`instanceName`, `instanceSecret`, and ports)
1516 - local SQLite + file storage data
1617
@@ -19,11 +20,15 @@ Common commands: · 2 unmodified lines
1920 ```bash
2021 executor doctor
2122 executor up
23+executor web
2224 executor backend --help
2325 executor gateway
26+bash executor/uninstall --yes
2427 ```
2528
2629 `executor up` runs the managed `convex-local-backend` binary directly without requiring users to install Bun, Node, or Convex.
30+On first run, `executor up` also bootstraps Convex functions automatically when a local project checkout is available.
31+`executor web` runs the packaged web UI on `http://localhost:5312`.
2732
2833 ## Local development from source
2934
@@ -84,6 +89,7 @@ Artifacts are written to `dist/release/`. · 54 unmodified lines
8489 - `run_code` attempts MCP form elicitation for pending tool approvals when the MCP client advertises `elicitation.form`; clients without elicitation support continue using out-of-band approval flow.
8590 - Local source-dev defaults: web `http://localhost:4312`, MCP gateway `http://localhost:4313/mcp`.
8691 - Installed binary default for `executor gateway`: `http://localhost:5313/mcp`.
92+- WorkOS env vars are optional for local self-hosted usage; WorkOS component wiring is enabled only when all required WorkOS env vars are present.
8793
8894 ## Credential providers
8995
executor/apps/web/next.config.ts+2−1
@@ -15,11 +15,12 @@ const appShellRewriteExclusions = [ · 14 unmodified lines
1515 ].join("|");
1616
1717 const nextConfig: NextConfig = {
18+ output: "standalone",
1819 transpilePackages: ["@executor/convex"],
1920 env: {
2021 // Map canonical env vars to NEXT_PUBLIC_ so they're available client-side.
2122 // This lets us keep a single root .env without NEXT_PUBLIC_ prefixes.
22− NEXT_PUBLIC_CONVEX_URL: process.env.CONVEX_URL,
23+ NEXT_PUBLIC_CONVEX_URL: process.env.EXECUTOR_WEB_CONVEX_URL ?? process.env.CONVEX_URL,
2324 NEXT_PUBLIC_WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID,
2425 NEXT_PUBLIC_STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID,
2526 },
executor/convex/_generated/api.d.ts+37−0
@@ -28,6 +28,43 @@ export declare const api: { · 27 unmodified lines
2828 any
2929 >;
3030 };
31+ anonymousOauth: {
32+ getActiveSigningKey: FunctionReference<
33+ "query",
34+ "public",
35+ { internalSecret: string },
36+ any
37+ >;
38+ getClient: FunctionReference<
39+ "query",
40+ "public",
41+ { clientId: string; internalSecret: string },
42+ any
43+ >;
44+ registerClient: FunctionReference<
45+ "mutation",
46+ "public",
47+ {
48+ clientId: string;
49+ clientName?: string;
50+ internalSecret: string;
51+ redirectUris: Array<string>;
52+ },
53+ any
54+ >;
55+ storeSigningKey: FunctionReference<
56+ "mutation",
57+ "public",
58+ {
59+ algorithm: string;
60+ internalSecret: string;
61+ keyId: string;
62+ privateKeyJwk: any;
63+ publicKeyJwk: any;
64+ },
65+ any
66+ >;
67+ };
3168 app: {
3269 getClientConfig: FunctionReference<"query", "public", {}, any>;
3370 getCurrentAccount: FunctionReference<
executor/convex/anonymousOauth.ts+85−0
@@ -0,0 +1,85 @@
1+/**
2+ * Public Convex functions for the self-issued anonymous OAuth server.
3+ *
4+ * These are called by the MCP gateway process (via ConvexHttpClient) to
5+ * persist signing keys and client registrations across restarts.
6+ *
7+ * All functions require an `internalSecret` argument that must match the
8+ * `EXECUTOR_INTERNAL_TOKEN` environment variable. This prevents arbitrary
9+ * callers from reading private key material or injecting signing keys.
10+ */
11+
12+import { v } from "convex/values";
13+import { internal } from "./_generated/api";
14+import { mutation, query } from "./_generated/server";
15+
16+function requireInternalSecret(secret: string): void {
17+ const expected = process.env.EXECUTOR_INTERNAL_TOKEN;
18+ if (!expected) {
19+ throw new Error("EXECUTOR_INTERNAL_TOKEN is not configured");
20+ }
21+ if (secret !== expected) {
22+ throw new Error("Unauthorized: invalid internal secret");
23+ }
24+}
25+
26+// ── Signing Keys ────────────────────────────────────────────────────────────
27+
28+export const getActiveSigningKey = query({
29+ args: { internalSecret: v.string() },
30+ handler: async (ctx, args) => {
31+ requireInternalSecret(args.internalSecret);
32+ return await ctx.runQuery(
33+ internal.database.getActiveAnonymousOauthSigningKey,
34+ {},
35+ );
36+ },
37+});
38+
39+export const storeSigningKey = mutation({
40+ args: {
41+ internalSecret: v.string(),
42+ keyId: v.string(),
43+ algorithm: v.string(),
44+ privateKeyJwk: v.any(),
45+ publicKeyJwk: v.any(),
46+ },
47+ handler: async (ctx, args) => {
48+ requireInternalSecret(args.internalSecret);
49+ const { internalSecret: _, ...keyArgs } = args;
50+ return await ctx.runMutation(
51+ internal.database.storeAnonymousOauthSigningKey,
52+ keyArgs,
53+ );
54+ },
55+});
56+
57+// ── Client Registrations ────────────────────────────────────────────────────
58+
59+export const registerClient = mutation({
60+ args: {
61+ internalSecret: v.string(),
62+ clientId: v.string(),
63+ clientName: v.optional(v.string()),
64+ redirectUris: v.array(v.string()),
65+ },
66+ handler: async (ctx, args) => {
67+ requireInternalSecret(args.internalSecret);
68+ const { internalSecret: _, ...clientArgs } = args;
69+ return await ctx.runMutation(
70+ internal.database.registerAnonymousOauthClient,
71+ clientArgs,
72+ );
73+ },
74+});
75+
76+export const getClient = query({
77+ args: { internalSecret: v.string(), clientId: v.string() },
78+ handler: async (ctx, args) => {
79+ requireInternalSecret(args.internalSecret);
80+ return await ctx.runQuery(
81+ internal.database.getAnonymousOauthClient,
82+ { clientId: args.clientId },
83+ );
84+ },
85+});
executor/convex/auth.ts+3−2
@@ -25,10 +25,11 @@ const workosEnabled = Boolean( · 24 unmodified lines
2525 process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET,
2626 );
2727
28−const authFunctions: AuthFunctions = internal.auth;
28+const authFunctions = (internal as Record<string, unknown>).auth as AuthFunctions;
29+const workosComponent = (components as Record<string, unknown>).workOSAuthKit;
2930
3031 const authKitInstance = workosEnabled
31− ? new AuthKit<DataModel>(components.workOSAuthKit, {
32+ ? new AuthKit<DataModel>(workosComponent as never, {
3233 authFunctions,
3334 additionalEventTypes: [
3435 "organization.created",
executor/convex/convex.config.ts+7−1
@@ -5,7 +5,13 @@ import { defineApp } from "convex/server"; · 4 unmodified lines
55
66 const app = defineApp();
77
8−app.use(workOSAuthKit);
8+const workosEnabled = Boolean(
9+ process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET,
10+);
11+
12+if (workosEnabled) {
13+ app.use(workOSAuthKit);
14+}
915 app.use(stripe);
1016 app.use(migrations);
1117
executor/executor.ts+46−2
@@ -1,7 +1,8 @@
11 #!/usr/bin/env bun
22
3+import fs from "node:fs/promises";
34 import { startMcpGateway } from "./mcp-gateway";
4−import { managedRuntimeDiagnostics, runManagedBackend } from "./lib/managed_runtime";
5+import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./lib/managed_runtime";
56
67 function printHelp(): void {
78 console.log(`Executor CLI
@@ -10,12 +11,14 @@ Usage: · 2 unmodified lines
1011 executor doctor
1112 executor up [backend-args]
1213 executor backend <args>
14+ executor web [--port <number>]
1315 executor gateway [--port <number>]
1416
1517 Commands:
1618 doctor Bootstrap and verify managed Convex backend runtime
17− up Run managed Convex backend directly (no Bun/Node/Convex install)
19+ up Run managed backend and auto-bootstrap Convex functions
1820 backend Pass through arguments to managed convex-local-backend binary
21+ web Run packaged web UI (default port: 5312)
1922 gateway Start Executor MCP gateway (default port: 5313)
2023 `);
2124 }
@@ -39,6 +42,28 @@ function parsePort(args: string[]): number | undefined { · 17 unmodified lines
3942 return port;
4043 }
4144
45+async function pathExists(filePath: string): Promise<boolean> {
46+ try {
47+ await fs.access(filePath);
48+ return true;
49+ } catch {
50+ return false;
51+ }
52+}
53+
54+async function checkHttp(url: string): Promise<boolean> {
55+ const controller = new AbortController();
56+ const timer = setTimeout(() => controller.abort(), 900);
57+ try {
58+ const response = await fetch(url, { signal: controller.signal });
59+ return response.ok;
60+ } catch {
61+ return false;
62+ } finally {
63+ clearTimeout(timer);
64+ }
65+}
66+
4267 async function run(): Promise<void> {
4368 const [command, ...rest] = process.argv.slice(2);
4469
@@ -49,11 +74,24 @@ async function run(): Promise<void> { · 4 unmodified lines
4974
5075 if (command === "doctor") {
5176 const info = await managedRuntimeDiagnostics();
77+ const webPort = Number(Bun.env.EXECUTOR_WEB_PORT ?? 5312);
78+ const gatewayPort = Number(Bun.env.EXECUTOR_MCP_GATEWAY_PORT ?? 5313);
79+ const webInstalled = await pathExists(info.webServerEntry);
80+ const nodeInstalled = await pathExists(info.nodeBin);
81+ const backendRunning = await checkHttp(`${info.convexUrl}/version`);
82+ const webRunning = await checkHttp(`http://127.0.0.1:${webPort}/`);
83+ const gatewayRunning = await checkHttp(`http://127.0.0.1:${gatewayPort}/health`);
84+
5285 console.log("Managed runtime ready");
5386 console.log(` root: ${info.rootDir}`);
5487 console.log(` backend: ${info.backendVersion} (${info.backendBinary})`);
5588 console.log(` convex URL: ${info.convexUrl}`);
5689 console.log(` convex site: ${info.convexSiteUrl}`);
90+ console.log(` node runtime: ${nodeInstalled ? info.nodeBin : "not installed yet (installed by 'executor web')"}`);
91+ console.log(` web bundle: ${webInstalled ? info.webServerEntry : "not installed yet (installed by 'executor web')"}`);
92+ console.log(` web URL: http://127.0.0.1:${webPort}`);
93+ console.log(` gateway URL: http://127.0.0.1:${gatewayPort}/mcp`);
94+ console.log(` running: backend=${backendRunning ? "yes" : "no"} web=${webRunning ? "yes" : "no"} gateway=${gatewayRunning ? "yes" : "no"}`);
5795 console.log(` config: ${info.configPath}`);
5896 return;
5997 }
@@ -77,6 +115,12 @@ async function run(): Promise<void> { · 17 unmodified lines
77115 return;
78116 }
79117
118+ if (command === "web") {
119+ const port = parsePort(rest);
120+ const exitCode = await runManagedWeb({ port });
121+ process.exit(exitCode);
122+ }
123+
80124 throw new Error(`Unknown command: ${command}`);
81125 }
82126
executor/install+80−2
@@ -4,11 +4,14 @@ set -euo pipefail · 3 unmodified lines
44 APP="executor"
55 REPO="${EXECUTOR_REPO:-RhysSullivan/assistant}"
66 INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}"
7+WEB_INSTALL_DIR="${EXECUTOR_WEB_INSTALL_DIR:-$HOME/.executor/runtime/web}"
78 REQUESTED_VERSION="${VERSION:-}"
9+RESOLVED_VERSION=""
810 BINARY_PATH=""
911 NO_MODIFY_PATH="0"
1012 SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
1113 LOCAL_BINARY_CANDIDATE="$SCRIPT_DIR/dist/executor"
14+LOCAL_WEB_CANDIDATE="$SCRIPT_DIR/dist/release"
1215
1316 RED='\033[0;31m'
1417 MUTED='\033[0;2m'
@@ -30,6 +33,7 @@ Options: · 15 unmodified lines
3033 Environment:
3134 EXECUTOR_REPO GitHub repository to install from (default: RhysSullivan/assistant)
3235 EXECUTOR_INSTALL_DIR Install directory (default: ~/.executor/bin)
36+ EXECUTOR_WEB_INSTALL_DIR Web bundle directory (default: ~/.executor/runtime/web)
3337 EOF
3438 }
3539
@@ -66,6 +70,10 @@ while [[ $# -gt 0 ]]; do · 30 unmodified lines
6670 esac
6771 done
6872
73+if [[ -n "$REQUESTED_VERSION" ]]; then
74+ RESOLVED_VERSION="${REQUESTED_VERSION#v}"
75+fi
76+
6977 raw_os="$(uname -s)"
7078 case "$raw_os" in
7179 Linux*) os="linux" ;;
@@ -90,16 +98,75 @@ archive_ext="zip" · 18 unmodified lines
9098 archive_ext="tar.gz"
9199
92100 filename="${APP}-${os}-${arch}.${archive_ext}"
101+web_filename="executor-web-${os}-${arch}.tar.gz"
93102 mkdir -p "$INSTALL_DIR"
103+mkdir -p "$WEB_INSTALL_DIR"
94104
95105 install_local_binary() {
96106 local src="$1"
107+ local tmp_target
97108 if [[ ! -f "$src" ]]; then
98109 echo -e "${RED}Local binary not found: ${src}${NC}"
99110 exit 1
100111 fi
101− cp "$src" "$INSTALL_DIR/$APP"
102− chmod 755 "$INSTALL_DIR/$APP"
112+ tmp_target="$INSTALL_DIR/.${APP}.tmp.$$"
113+ cp "$src" "$tmp_target"
114+ chmod 755 "$tmp_target"
115+ mv -f "$tmp_target" "$INSTALL_DIR/$APP"
116+}
117+
118+install_web_bundle_from_archive() {
119+ local archive_path="$1"
120+ local tmp_web_dir
121+ tmp_web_dir="$(mktemp -d)"
122+ if ! tar -xzf "$archive_path" -C "$tmp_web_dir"; then
123+ rm -rf "$tmp_web_dir"
124+ echo -e "${RED}Failed extracting web archive${NC}"
125+ exit 1
126+ fi
127+ if [[ ! -f "$tmp_web_dir/server.js" ]]; then
128+ rm -rf "$tmp_web_dir"
129+ echo -e "${RED}Web archive missing expected server.js entry${NC}"
130+ exit 1
131+ fi
132+
133+ rm -rf "$WEB_INSTALL_DIR"
134+ mkdir -p "$WEB_INSTALL_DIR"
135+ cp -R "$tmp_web_dir"/. "$WEB_INSTALL_DIR"
136+ rm -rf "$tmp_web_dir"
137+}
138+
139+install_web_bundle() {
140+ local web_url
141+ local temp_archive
142+ temp_archive="$(mktemp)"
143+
144+ if [[ -z "$REQUESTED_VERSION" ]]; then
145+ web_url="https://github.com/${REPO}/releases/latest/download/${web_filename}"
146+ else
147+ web_url="https://github.com/${REPO}/releases/download/v${RESOLVED_VERSION}/${web_filename}"
148+ fi
149+
150+ if curl -fsSL "$web_url" -o "$temp_archive"; then
151+ echo -e "${MUTED}Installing web bundle${NC} ${web_filename}"
152+ install_web_bundle_from_archive "$temp_archive"
153+ rm -f "$temp_archive"
154+ return
155+ fi
156+ rm -f "$temp_archive"
157+
158+ local local_web_archive="$LOCAL_WEB_CANDIDATE/${web_filename}"
159+ if [[ -f "$local_web_archive" ]]; then
160+ echo -e "${MUTED}Web bundle release asset not found; using local archive at ${local_web_archive}${NC}"
161+ install_web_bundle_from_archive "$local_web_archive"
162+ return
163+ fi
164+
165+ echo -e "${RED}Web bundle was not installed (missing release asset ${web_filename})${NC}"
166+ echo ""
167+ echo "To build and install it locally:"
168+ echo " bun run --cwd executor build:release"
169+ echo " bash executor/install"
103170 }
104171
105172 if [[ -n "$BINARY_PATH" ]]; then
@@ -113,8 +180,10 @@ else · 7 unmodified lines
113180 if [[ -z "$version" ]]; then
114181 version="latest"
115182 fi
183+ RESOLVED_VERSION="${version#v}"
116184 else
117185 version="${REQUESTED_VERSION#v}"
186+ RESOLVED_VERSION="$version"
118187 url="https://github.com/${REPO}/releases/download/v${version}/${filename}"
119188 fi
120189
@@ -147,6 +216,8 @@ else · 26 unmodified lines
147216 fi
148217 fi
149218
219+install_web_bundle
220+
150221 if [[ "$NO_MODIFY_PATH" != "1" ]]; then
151222 shell_name="$(basename "${SHELL:-}")"
152223 case "$shell_name" in
@@ -165,7 +236,14 @@ fi · 12 unmodified lines
165236
166237 echo ""
167238 echo "Installed to: $INSTALL_DIR/$APP"
239+if [[ -f "$WEB_INSTALL_DIR/server.js" ]]; then
240+ echo "Web bundle installed to: $WEB_INSTALL_DIR"
241+fi
168242 echo "Run:"
169243 echo " executor doctor"
170244 echo " executor up"
245+echo " executor web # defaults to http://localhost:5312"
171246 echo " executor gateway # defaults to http://localhost:5313/mcp"
247+echo ""
248+echo "To uninstall later:"
249+echo " bash executor/uninstall --yes"
executor/lib/anonymous-oauth.test.ts+180−38
@@ -1,6 +1,7 @@
11 import { describe, test, expect, beforeAll } from "bun:test";
22 import {
33 AnonymousOAuthServer,
4+ InMemoryOAuthStorage,
45 OAuthBadRequest,
56 computeS256Challenge,
67 } from "./anonymous-oauth";
@@ -28,7 +29,7 @@ async function registerAndAuthorize( · 21 unmodified lines
2829 server: AnonymousOAuthServer,
2930 redirectUri = "http://localhost:9999/callback",
3031 ) {
31− const client = server.registerClient({
32+ const client = await server.registerClient({
3233 redirect_uris: [redirectUri],
3334 client_name: "test-client",
3435 });
@@ -45,7 +46,7 @@ async function registerAndAuthorize( · 10 unmodified lines
4546 state: "test-state-123",
4647 });
4748
48− const { redirectTo } = server.authorize(params);
49+ const { redirectTo } = await server.authorize(params);
4950 const redirectUrl = new URL(redirectTo);
5051 const code = redirectUrl.searchParams.get("code")!;
5152
@@ -58,9 +59,14 @@ async function registerAndAuthorize( · 6 unmodified lines
5859
5960 describe("AnonymousOAuthServer", () => {
6061 let server: AnonymousOAuthServer;
62+ let storage: InMemoryOAuthStorage;
6163
6264 beforeAll(async () => {
63− server = createServer();
65+ storage = new InMemoryOAuthStorage();
66+ server = new AnonymousOAuthServer({
67+ issuer: "http://localhost:3003",
68+ storage,
69+ });
6470 await server.init();
6571 });
6672
@@ -106,8 +112,8 @@ describe("AnonymousOAuthServer", () => { · 39 unmodified lines
106112 // ── Client Registration ─────────────────────────────────────────────────
107113
108114 describe("registerClient", () => {
109− test("registers a client and returns a client_id", () => {
110− const reg = server.registerClient({
115+ test("registers a client and returns a client_id", async () => {
116+ const reg = await server.registerClient({
111117 redirect_uris: ["http://localhost:9999/callback"],
112118 client_name: "my-mcp-client",
113119 });
@@ -118,22 +124,28 @@ describe("AnonymousOAuthServer", () => { · 4 unmodified lines
118124 expect(reg.created_at).toBeGreaterThan(0);
119125 });
120126
121− test("rejects registration without redirect_uris", () => {
122− expect(() => server.registerClient({} as any)).toThrow(OAuthBadRequest);
127+ test("rejects registration without redirect_uris", async () => {
128+ await expect(server.registerClient({} as any)).rejects.toThrow(OAuthBadRequest);
123129 });
124130
125− test("rejects registration with empty redirect_uris", () => {
126− expect(() => server.registerClient({ redirect_uris: [] })).toThrow(OAuthBadRequest);
131+ test("rejects registration with empty redirect_uris", async () => {
132+ await expect(server.registerClient({ redirect_uris: [] })).rejects.toThrow(OAuthBadRequest);
127133 });
128134
129− test("rejects registration with invalid redirect_uri entries", () => {
130− expect(() => server.registerClient({ redirect_uris: [""] })).toThrow(OAuthBadRequest);
135+ test("rejects registration with invalid redirect_uri entries", async () => {
136+ await expect(server.registerClient({ redirect_uris: [""] })).rejects.toThrow(OAuthBadRequest);
131137 });
132138
133− test("increments client count", () => {
134− const before = server.getClientCount();
135− server.registerClient({ redirect_uris: ["http://example.com/cb"] });
136− expect(server.getClientCount()).toBe(before + 1);
139+ test("rejects registration with non-URL redirect_uri", async () => {
140+ await expect(
141+ server.registerClient({ redirect_uris: ["not-a-url"] }),
142+ ).rejects.toThrow("Invalid redirect_uri");
143+ });
144+
145+ test("increments client count in storage", async () => {
146+ const before = storage.clientCount;
147+ await server.registerClient({ redirect_uris: ["http://example.com/cb"] });
148+ expect(storage.clientCount).toBe(before + 1);
137149 });
138150 });
139151
@@ -148,8 +160,8 @@ describe("AnonymousOAuthServer", () => { · 8 unmodified lines
148160 expect(redirectUrl.pathname).toBe("/callback");
149161 });
150162
151− test("rejects unsupported response_type", () => {
152− const client = server.registerClient({
163+ test("rejects unsupported response_type", async () => {
164+ const client = await server.registerClient({
153165 redirect_uris: ["http://localhost/cb"],
154166 });
155167 const params = new URLSearchParams({
@@ -159,10 +171,10 @@ describe("AnonymousOAuthServer", () => { · 3 unmodified lines
159171 code_challenge: "abc",
160172 code_challenge_method: "S256",
161173 });
162− expect(() => server.authorize(params)).toThrow("response_type must be 'code'");
174+ await expect(server.authorize(params)).rejects.toThrow("response_type must be 'code'");
163175 });
164176
165− test("rejects unknown client_id", () => {
177+ test("rejects unknown client_id", async () => {
166178 const params = new URLSearchParams({
167179 response_type: "code",
168180 client_id: "unknown",
@@ -170,11 +182,11 @@ describe("AnonymousOAuthServer", () => { · 1 unmodified line
170182 code_challenge: "abc",
171183 code_challenge_method: "S256",
172184 });
173− expect(() => server.authorize(params)).toThrow("Unknown client_id");
185+ await expect(server.authorize(params)).rejects.toThrow("Unknown client_id");
174186 });
175187
176− test("rejects mismatched redirect_uri", () => {
177− const client = server.registerClient({
188+ test("rejects mismatched redirect_uri", async () => {
189+ const client = await server.registerClient({
178190 redirect_uris: ["http://localhost/cb"],
179191 });
180192 const params = new URLSearchParams({
@@ -184,11 +196,11 @@ describe("AnonymousOAuthServer", () => { · 3 unmodified lines
184196 code_challenge: "abc",
185197 code_challenge_method: "S256",
186198 });
187− expect(() => server.authorize(params)).toThrow("redirect_uri does not match");
199+ await expect(server.authorize(params)).rejects.toThrow("redirect_uri does not match");
188200 });
189201
190− test("rejects missing PKCE challenge", () => {
191− const client = server.registerClient({
202+ test("rejects missing PKCE challenge", async () => {
203+ const client = await server.registerClient({
192204 redirect_uris: ["http://localhost/cb"],
193205 });
194206 const params = new URLSearchParams({
@@ -196,11 +208,11 @@ describe("AnonymousOAuthServer", () => { · 1 unmodified line
196208 client_id: client.client_id,
197209 redirect_uri: "http://localhost/cb",
198210 });
199− expect(() => server.authorize(params)).toThrow("PKCE S256 code_challenge is required");
211+ await expect(server.authorize(params)).rejects.toThrow("PKCE S256 code_challenge is required");
200212 });
201213
202− test("rejects non-S256 PKCE method", () => {
203− const client = server.registerClient({
214+ test("rejects non-S256 PKCE method", async () => {
215+ const client = await server.registerClient({
204216 redirect_uris: ["http://localhost/cb"],
205217 });
206218 const params = new URLSearchParams({
@@ -210,7 +222,7 @@ describe("AnonymousOAuthServer", () => { · 3 unmodified lines
210222 code_challenge: "abc",
211223 code_challenge_method: "plain",
212224 });
213− expect(() => server.authorize(params)).toThrow("PKCE S256 code_challenge is required");
225+ await expect(server.authorize(params)).rejects.toThrow("PKCE S256 code_challenge is required");
214226 });
215227 });
216228
@@ -218,12 +230,13 @@ describe("AnonymousOAuthServer", () => { · 1 unmodified line
218230
219231 describe("exchangeToken", () => {
220232 test("exchanges code for a valid JWT access token", async () => {
221− const { code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
233+ const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
222234
223235 const result = await server.exchangeToken(
224236 new URLSearchParams({
225237 grant_type: "authorization_code",
226238 code,
239+ client_id: client.client_id,
227240 redirect_uri: redirectUri,
228241 code_verifier: codeVerifier,
229242 }),
@@ -241,11 +254,12 @@ describe("AnonymousOAuthServer", () => { · 11 unmodified lines
241254 });
242255
243256 test("code is single-use", async () => {
244− const { code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
257+ const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
245258
246259 const body = new URLSearchParams({
247260 grant_type: "authorization_code",
248261 code,
262+ client_id: client.client_id,
249263 redirect_uri: redirectUri,
250264 code_verifier: codeVerifier,
251265 });
@@ -263,6 +277,22 @@ describe("AnonymousOAuthServer", () => { · 11 unmodified lines
263277 ).rejects.toThrow("grant_type must be authorization_code");
264278 });
265279
280+ test("rejects wrong client_id", async () => {
281+ const { code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
282+
283+ await expect(
284+ server.exchangeToken(
285+ new URLSearchParams({
286+ grant_type: "authorization_code",
287+ code,
288+ client_id: "anon_client_wrong",
289+ redirect_uri: redirectUri,
290+ code_verifier: codeVerifier,
291+ }),
292+ ),
293+ ).rejects.toThrow("client_id mismatch");
294+ });
295+
266296 test("rejects expired code", async () => {
267297 // Create server with very short code expiry
268298 const shortServer = new AnonymousOAuthServer({
@@ -271,7 +301,7 @@ describe("AnonymousOAuthServer", () => { · 2 unmodified lines
271301 });
272302 await shortServer.init();
273303
274− const { code, codeVerifier, redirectUri } = await registerAndAuthorize(shortServer);
304+ const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(shortServer);
275305
276306 // Wait a tick for the code to expire
277307 await new Promise((resolve) => setTimeout(resolve, 10));
@@ -281,6 +311,7 @@ describe("AnonymousOAuthServer", () => { · 3 unmodified lines
281311 new URLSearchParams({
282312 grant_type: "authorization_code",
283313 code,
314+ client_id: client.client_id,
284315 redirect_uri: redirectUri,
285316 code_verifier: codeVerifier,
286317 }),
@@ -289,13 +320,14 @@ describe("AnonymousOAuthServer", () => { · 2 unmodified lines
289320 });
290321
291322 test("rejects wrong redirect_uri", async () => {
292− const { code, codeVerifier } = await registerAndAuthorize(server);
323+ const { client, code, codeVerifier } = await registerAndAuthorize(server);
293324
294325 await expect(
295326 server.exchangeToken(
296327 new URLSearchParams({
297328 grant_type: "authorization_code",
298329 code,
330+ client_id: client.client_id,
299331 redirect_uri: "http://evil.com/cb",
300332 code_verifier: codeVerifier,
301333 }),
@@ -304,13 +336,14 @@ describe("AnonymousOAuthServer", () => { · 2 unmodified lines
304336 });
305337
306338 test("rejects wrong code_verifier", async () => {
307− const { code, redirectUri } = await registerAndAuthorize(server);
339+ const { client, code, redirectUri } = await registerAndAuthorize(server);
308340
309341 await expect(
310342 server.exchangeToken(
311343 new URLSearchParams({
312344 grant_type: "authorization_code",
313345 code,
346+ client_id: client.client_id,
314347 redirect_uri: redirectUri,
315348 code_verifier: "wrong-verifier-value",
316349 }),
@@ -319,13 +352,14 @@ describe("AnonymousOAuthServer", () => { · 2 unmodified lines
319352 });
320353
321354 test("rejects missing code_verifier", async () => {
322− const { code, redirectUri } = await registerAndAuthorize(server);
355+ const { client, code, redirectUri } = await registerAndAuthorize(server);
323356
324357 await expect(
325358 server.exchangeToken(
326359 new URLSearchParams({
327360 grant_type: "authorization_code",
328361 code,
362+ client_id: client.client_id,
329363 redirect_uri: redirectUri,
330364 }),
331365 ),
@@ -337,11 +371,12 @@ describe("AnonymousOAuthServer", () => { · 5 unmodified lines
337371
338372 describe("verifyToken", () => {
339373 test("verifies a self-issued token", async () => {
340− const { code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
374+ const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
341375 const { access_token } = await server.exchangeToken(
342376 new URLSearchParams({
343377 grant_type: "authorization_code",
344378 code,
379+ client_id: client.client_id,
345380 redirect_uri: redirectUri,
346381 code_verifier: codeVerifier,
347382 }),
@@ -362,11 +397,12 @@ describe("AnonymousOAuthServer", () => { · 14 unmodified lines
362397 const otherServer = createServer("http://other-server:4000");
363398 await otherServer.init();
364399
365− const { code, codeVerifier, redirectUri } = await registerAndAuthorize(otherServer);
400+ const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(otherServer);
366401 const { access_token } = await otherServer.exchangeToken(
367402 new URLSearchParams({
368403 grant_type: "authorization_code",
369404 code,
405+ client_id: client.client_id,
370406 redirect_uri: redirectUri,
371407 code_verifier: codeVerifier,
372408 }),
@@ -414,6 +450,111 @@ describe("AnonymousOAuthServer", () => { · 41 unmodified lines
414450 });
415451 });
416452
453+ // ── Authorization code cap ────────────────────────────────────────────────
454+
455+ describe("authorization code cap", () => {
456+ test("rejects new authorizations when at max pending codes", async () => {
457+ const s = new AnonymousOAuthServer({
458+ issuer: "http://localhost:3003",
459+ maxPendingCodes: 2,
460+ codeExpirySeconds: 300, // long-lived so they don't auto-purge
461+ });
462+ await s.init();
463+
464+ // Fill up the code slots
465+ await registerAndAuthorize(s);
466+ await registerAndAuthorize(s);
467+ expect(s.getCodeCount()).toBe(2);
468+
469+ // Third should be rejected
470+ await expect(registerAndAuthorize(s)).rejects.toThrow(
471+ "Too many pending authorization requests",
472+ );
473+ });
474+
475+ test("auto-purges expired codes before rejecting", async () => {
476+ const s = new AnonymousOAuthServer({
477+ issuer: "http://localhost:3003",
478+ maxPendingCodes: 2,
479+ codeExpirySeconds: 0, // codes expire immediately
480+ });
481+ await s.init();
482+
483+ // Fill slots (they expire immediately)
484+ await registerAndAuthorize(s);
485+ await registerAndAuthorize(s);
486+ expect(s.getCodeCount()).toBe(2);
487+
488+ // Wait for expiry
489+ await new Promise((resolve) => setTimeout(resolve, 10));
490+
491+ // Should succeed because expired codes are purged
492+ const { code } = await registerAndAuthorize(s);
493+ expect(code).toBeTruthy();
494+ });
495+ });
496+
497+ // ── Key persistence via storage ─────────────────────────────────────────
498+
499+ describe("key persistence", () => {
500+ test("reuses key from storage on second init", async () => {
501+ const sharedStorage = new InMemoryOAuthStorage();
502+
503+ // First server generates a key
504+ const s1 = new AnonymousOAuthServer({
505+ issuer: "http://localhost:3003",
506+ storage: sharedStorage,
507+ });
508+ await s1.init();
509+ const jwks1 = s1.getJwks();
510+
511+ // Second server should load the same key from storage
512+ const s2 = new AnonymousOAuthServer({
513+ issuer: "http://localhost:3003",
514+ storage: sharedStorage,
515+ });
516+ await s2.init();
517+ const jwks2 = s2.getJwks();
518+
519+ expect(jwks1.keys[0].kid).toBe(jwks2.keys[0].kid);
520+ expect(jwks1.keys[0].n).toBe(jwks2.keys[0].n);
521+ });
522+
523+ test("token from first server is verified by second server with same storage", async () => {
524+ const sharedStorage = new InMemoryOAuthStorage();
525+
526+ const s1 = new AnonymousOAuthServer({
527+ issuer: "http://localhost:3003",
528+ storage: sharedStorage,
529+ });
530+ await s1.init();
531+
532+ const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(s1);
533+ const { access_token } = await s1.exchangeToken(
534+ new URLSearchParams({
535+ grant_type: "authorization_code",
536+ code,
537+ client_id: client.client_id,
538+ redirect_uri: redirectUri,
539+ code_verifier: codeVerifier,
540+ }),
541+ );
542+
543+ // "Restart" the gateway — new server instance, same storage
544+ const s2 = new AnonymousOAuthServer({
545+ issuer: "http://localhost:3003",
546+ storage: sharedStorage,
547+ });
548+ await s2.init();
549+
550+ // Token from s1 should be valid on s2
551+ const verified = await s2.verifyToken(access_token);
552+ expect(verified).not.toBeNull();
553+ expect(verified!.sub).toStartWith("anon_");
554+ expect(verified!.provider).toBe("anonymous");
555+ });
556+ });
557+
417558 // ── Each authorize creates a unique actor ───────────────────────────────
418559
419560 describe("unique anonymous identities", () => {
@@ -421,11 +562,12 @@ describe("AnonymousOAuthServer", () => { · 1 unmodified line
421562 const subs = new Set<string>();
422563
423564 for (let i = 0; i < 5; i++) {
424− const { code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
565+ const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
425566 const { access_token } = await server.exchangeToken(
426567 new URLSearchParams({
427568 grant_type: "authorization_code",
428569 code,
570+ client_id: client.client_id,
429571 redirect_uri: redirectUri,
430572 code_verifier: codeVerifier,
431573 }),
executor/lib/anonymous-oauth.ts+56−20
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
3636 jwtVerify,
3737 type JWK,
3838 type CryptoKey as JoseCryptoKey,
39+ type JWTVerifyGetKey,
3940 } from "jose";
4041
4142 // ---------------------------------------------------------------------------
@@ -66,6 +67,8 @@ export interface AnonOAuthConfig { · 24 unmodified lines
6667 accessTokenTtlSeconds?: number;
6768 /** Authorization code TTL in seconds. Default 120 s. */
6869 codeExpirySeconds?: number;
70+ /** Maximum number of in-flight authorization codes. Default 10 000. */
71+ maxPendingCodes?: number;
6972 /** Pluggable storage backend for keys & client registrations. */
7073 storage?: OAuthStorage;
7174 }
@@ -141,21 +144,27 @@ export class InMemoryOAuthStorage implements OAuthStorage { · 69 unmodified lines
141144
142145 export class AnonymousOAuthServer {
143146 private readonly issuer: string;
147+ private readonly audience: string;
144148 private readonly accessTokenTtlSeconds: number;
145149 private readonly codeExpirySeconds: number;
150+ private readonly maxPendingCodes: number;
146151 private readonly storage: OAuthStorage;
147152
148153 private privateKey!: JoseCryptoKey;
149154 private publicJwk!: JWK;
150155 private keyId!: string;
156+ /** Cached local JWKS for fast token verification (built once on init). */
157+ private localJwks!: JWTVerifyGetKey;
151158
152159 /** In-memory authorization codes (short-lived, cleaned up on use). */
153160 private readonly codes = new Map<string, AuthorizationCode>();
154161
155162 constructor(config: AnonOAuthConfig) {
156163 this.issuer = config.issuer.replace(/\/+$/, "");
164+ this.audience = `${this.issuer}/mcp`;
157165 this.accessTokenTtlSeconds = config.accessTokenTtlSeconds ?? 24 * 60 * 60;
158166 this.codeExpirySeconds = config.codeExpirySeconds ?? 120;
167+ this.maxPendingCodes = config.maxPendingCodes ?? 10_000;
159168 this.storage = config.storage ?? new InMemoryOAuthStorage();
160169 }
161170
@@ -178,32 +187,38 @@ export class AnonymousOAuthServer { · 16 unmodified lines
178187 this.keyId = existing.keyId;
179188 this.publicJwk = { ...existing.publicKeyJwk, kid: this.keyId, use: "sig", alg: existing.algorithm };
180189 this.privateKey = await importJWK(existing.privateKeyJwk, existing.algorithm) as JoseCryptoKey;
181− return;
190+ } else {
191+ // No existing key — generate a fresh pair and persist it
192+ const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
193+ this.privateKey = privateKey;
194+ this.keyId = `anon_key_${crypto.randomUUID().slice(0, 8)}`;
195+
196+ const publicJwk = await exportJWK(publicKey);
197+ this.publicJwk = { ...publicJwk, kid: this.keyId, use: "sig", alg: "RS256" };
198+
199+ const privateKeyJwk = await exportJWK(privateKey);
200+
201+ await this.storage.storeSigningKey({
202+ keyId: this.keyId,
203+ algorithm: "RS256",
204+ privateKeyJwk,
205+ publicKeyJwk: publicJwk,
206+ });
182207 }
183208
184− // No existing key — generate a fresh pair and persist it
185− const { privateKey, publicKey } = await generateKeyPair("RS256");
186− this.privateKey = privateKey;
187− this.keyId = `anon_key_${crypto.randomUUID().slice(0, 8)}`;
188−
189− const publicJwk = await exportJWK(publicKey);
190− this.publicJwk = { ...publicJwk, kid: this.keyId, use: "sig", alg: "RS256" };
191−
192− const privateKeyJwk = await exportJWK(privateKey);
193−
194− await this.storage.storeSigningKey({
195− keyId: this.keyId,
196− algorithm: "RS256",
197− privateKeyJwk,
198− publicKeyJwk: publicJwk,
199− });
209+ // Build cached local JWKS for fast token verification
210+ this.localJwks = createLocalJWKSet({ keys: [this.publicJwk] });
200211 }
201212
202− /** Import an existing key pair (for tests or manual persistence). */
213+ /**
214+ * Import an existing key pair (for tests only — bypasses storage).
215+ * @internal
216+ */
203217 async initWithKeys(privateKey: JoseCryptoKey, publicJwk: JWK): Promise<void> {
204218 this.privateKey = privateKey;
205219 this.keyId = publicJwk.kid ?? `anon_key_${crypto.randomUUID().slice(0, 8)}`;
206220 this.publicJwk = { ...publicJwk, kid: this.keyId, use: "sig", alg: "RS256" };
221+ this.localJwks = createLocalJWKSet({ keys: [this.publicJwk] });
207222 }
208223
209224 // -------------------------------------------------------------------------
@@ -250,6 +265,11 @@ export class AnonymousOAuthServer { · 40 unmodified lines
250265 if (typeof uri !== "string" || uri.length === 0) {
251266 throw new OAuthBadRequest("Each redirect_uri must be a non-empty string");
252267 }
268+ try {
269+ new URL(uri);
270+ } catch {
271+ throw new OAuthBadRequest(`Invalid redirect_uri: ${uri}`);
272+ }
253273 }
254274
255275 const clientId = `anon_client_${crypto.randomUUID()}`;
@@ -305,6 +325,15 @@ export class AnonymousOAuthServer { · 49 unmodified lines
305325 // Generate anonymous identity
306326 const actorId = `anon_${crypto.randomUUID()}`;
307327
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) {
333+ throw new OAuthBadRequest("Too many pending authorization requests — try again later");
334+ }
335+ }
336+
308337 // Issue authorization code (always in-memory — short-lived, single-use)
309338 const code = crypto.randomUUID();
310339 this.codes.set(code, {
@@ -363,6 +392,12 @@ export class AnonymousOAuthServer { · 52 unmodified lines
363392 throw new OAuthBadRequest("authorization code has expired");
364393 }
365394
395+ // Verify client_id matches the code's originating client (RFC 6749 §4.1.3)
396+ const clientId = body.get("client_id");
397+ if (clientId !== storedCode.clientId) {
398+ throw new OAuthBadRequest("client_id mismatch");
399+ }
400+
366401 // Verify redirect_uri matches
367402 const redirectUri = body.get("redirect_uri");
368403 if (redirectUri !== storedCode.redirectUri) {
@@ -387,6 +422,7 @@ export class AnonymousOAuthServer { · 18 unmodified lines
387422 })
388423 .setProtectedHeader({ alg: "RS256", kid: this.keyId })
389424 .setIssuer(this.issuer)
425+ .setAudience(this.audience)
390426 .setIssuedAt()
391427 .setExpirationTime(`${this.accessTokenTtlSeconds}s`)
392428 .setJti(crypto.randomUUID())
@@ -407,9 +443,9 @@ export class AnonymousOAuthServer { · 14 unmodified lines
407443 token: string,
408444 ): Promise<{ sub: string; provider: string } | null> {
409445 try {
410− const jwks = createLocalJWKSet({ keys: [this.publicJwk] });
411− const { payload } = await jwtVerify(token, jwks, {
446+ const { payload } = await jwtVerify(token, this.localJwks, {
412447 issuer: this.issuer,
448+ audience: this.audience,
413449 });
414450
415451 if (typeof payload.sub !== "string" || payload.sub.length === 0) {
executor/lib/convex-oauth-storage.ts+100−0
@@ -0,0 +1,100 @@
1+/**
2+ * Convex-backed storage for the anonymous OAuth server.
3+ *
4+ * Persists signing keys and client registrations to Convex so they
5+ * survive gateway restarts. Keys are cached locally after first load
6+ * for fast in-process token verification.
7+ *
8+ * All Convex calls include an `internalSecret` argument that is validated
9+ * server-side against `EXECUTOR_INTERNAL_TOKEN`, preventing unauthorized
10+ * access to private key material.
11+ */
12+
13+import { ConvexHttpClient } from "convex/browser";
14+import { api } from "../convex/_generated/api";
15+import type {
16+ OAuthStorage,
17+ StoredSigningKey,
18+ AnonOAuthClientRegistration,
19+} from "./anonymous-oauth";
20+
21+export class ConvexOAuthStorage implements OAuthStorage {
22+ private readonly convex: ConvexHttpClient;
23+ private readonly internalSecret: string;
24+
25+ constructor(convexUrl: string, internalSecret: string) {
26+ if (!internalSecret) {
27+ throw new Error(
28+ "ConvexOAuthStorage requires an internalSecret (EXECUTOR_INTERNAL_TOKEN)",
29+ );
30+ }
31+ this.convex = new ConvexHttpClient(convexUrl);
32+ this.internalSecret = internalSecret;
33+ }
34+
35+ async getActiveSigningKey(): Promise<StoredSigningKey | null> {
36+ const result = await this.convex.query(
37+ api.anonymousOauth.getActiveSigningKey,
38+ { internalSecret: this.internalSecret },
39+ );
40+
41+ if (!result) return null;
42+
43+ return {
44+ keyId: result.keyId,
45+ algorithm: result.algorithm,
46+ privateKeyJwk: result.privateKeyJwk,
47+ publicKeyJwk: result.publicKeyJwk,
48+ };
49+ }
50+
51+ async storeSigningKey(key: StoredSigningKey): Promise<void> {
52+ await this.convex.mutation(
53+ api.anonymousOauth.storeSigningKey,
54+ {
55+ internalSecret: this.internalSecret,
56+ keyId: key.keyId,
57+ algorithm: key.algorithm,
58+ privateKeyJwk: key.privateKeyJwk,
59+ publicKeyJwk: key.publicKeyJwk,
60+ },
61+ );
62+ }
63+
64+ async registerClient(
65+ registration: AnonOAuthClientRegistration,
66+ ): Promise<AnonOAuthClientRegistration> {
67+ const result = await this.convex.mutation(
68+ api.anonymousOauth.registerClient,
69+ {
70+ internalSecret: this.internalSecret,
71+ clientId: registration.client_id,
72+ clientName: registration.client_name,
73+ redirectUris: registration.redirect_uris,
74+ },
75+ );
76+
77+ return {
78+ client_id: result.client_id,
79+ client_name: result.client_name ?? undefined,
80+ redirect_uris: result.redirect_uris,
81+ created_at: result.created_at,
82+ };
83+ }
84+
85+ async getClient(clientId: string): Promise<AnonOAuthClientRegistration | null> {
86+ const result = await this.convex.query(
87+ api.anonymousOauth.getClient,
88+ { internalSecret: this.internalSecret, clientId },
89+ );
90+
91+ if (!result) return null;
92+
93+ return {
94+ client_id: result.client_id,
95+ client_name: result.client_name ?? undefined,
96+ redirect_uris: result.redirect_uris,
97+ created_at: result.created_at,
98+ };
99+ }
100+}
executor/lib/managed_runtime.ts+392−15
@@ -3,6 +3,13 @@ import os from "node:os"; · 2 unmodified lines
33 import path from "node:path";
44
55 const CONVEX_BACKEND_REPO = "get-convex/convex-backend";
6+const EXECUTOR_RELEASE_REPO = Bun.env.EXECUTOR_REPO ?? "RhysSullivan/assistant";
7+const NODE_VERSION = "22.22.0";
8+const CONVEX_CLI_VERSION = "1.31.7";
9+const CONVEX_CLIENT_HEADER = `npm-cli-${CONVEX_CLI_VERSION}`;
10+
11+type HostPlatform = "linux" | "darwin";
12+type HostArch = "x64" | "arm64";
613
714 export interface ManagedRuntimeConfig {
815 instanceName: string;
@@ -22,6 +29,25 @@ export interface ManagedRuntimeInfo { · 13 unmodified lines
2229 storageDir: string;
2330 configPath: string;
2431 config: ManagedRuntimeConfig;
32+ nodeDir: string;
33+ nodeBin: string;
34+ npmBin: string;
35+ npmPrefix: string;
36+ convexCliEntry: string;
37+ webDir: string;
38+ webServerEntry: string;
39+ webArtifactName: string;
40+ webDownloadUrl: string;
41+}
42+
43+function hostTarget(): { platform: HostPlatform; arch: HostArch } {
44+ if (process.platform !== "linux" && process.platform !== "darwin") {
45+ throw new Error(`Unsupported platform: ${process.platform}. Supported platforms are linux and darwin.`);
46+ }
47+ if (process.arch !== "x64" && process.arch !== "arm64") {
48+ throw new Error(`Unsupported architecture: ${process.arch}. Supported architectures are x64 and arm64.`);
49+ }
50+ return { platform: process.platform, arch: process.arch };
2551 }
2652
2753 function runtimeRootDir(): string {
@@ -37,22 +63,36 @@ function backendBinaryName(): string { · 9 unmodified lines
3763 }
3864
3965 function backendAssetName(): string {
40− if (process.platform === "linux" && process.arch === "x64") {
66+ const target = hostTarget();
67+ if (target.platform === "linux" && target.arch === "x64") {
4168 return "convex-local-backend-x86_64-unknown-linux-gnu.zip";
4269 }
43− if (process.platform === "linux" && process.arch === "arm64") {
70+ if (target.platform === "linux" && target.arch === "arm64") {
4471 return "convex-local-backend-aarch64-unknown-linux-gnu.zip";
4572 }
46− if (process.platform === "darwin" && process.arch === "x64") {
73+ if (target.platform === "darwin" && target.arch === "x64") {
4774 return "convex-local-backend-x86_64-apple-darwin.zip";
4875 }
49− if (process.platform === "darwin" && process.arch === "arm64") {
50− return "convex-local-backend-aarch64-apple-darwin.zip";
51− }
76+ return "convex-local-backend-aarch64-apple-darwin.zip";
77+}
5278
53− throw new Error(
54− `Unsupported platform/arch for managed Convex backend: ${process.platform}/${process.arch}`,
55− );
79+function webAssetName(): string {
80+ const target = hostTarget();
81+ return `executor-web-${target.platform}-${target.arch}.tar.gz`;
82+}
83+
84+function nodeArchiveName(): string {
85+ const target = hostTarget();
86+ return `node-v${NODE_VERSION}-${target.platform}-${target.arch}.tar.gz`;
87+}
88+
89+function nodeDirectoryName(): string {
90+ const target = hostTarget();
91+ return `node-v${NODE_VERSION}-${target.platform}-${target.arch}`;
92+}
93+
94+function npmBinaryName(): string {
95+ return process.platform === "win32" ? "npm.cmd" : "npm";
5696 }
5797
5898 function randomHex(bytes: number): string {
@@ -65,7 +105,7 @@ function defaultConfig(): ManagedRuntimeConfig { · 6 unmodified lines
65105 const backendPort = Number(Bun.env.EXECUTOR_BACKEND_PORT ?? 5410);
66106 const siteProxyPort = Number(Bun.env.EXECUTOR_BACKEND_SITE_PORT ?? 5411);
67107 return {
68− instanceName: Bun.env.EXECUTOR_INSTANCE_NAME ?? "executor-local",
108+ instanceName: Bun.env.EXECUTOR_INSTANCE_NAME ?? "anonymous-executor",
69109 instanceSecret: Bun.env.EXECUTOR_INSTANCE_SECRET ?? randomHex(32),
70110 hostInterface: Bun.env.EXECUTOR_BACKEND_INTERFACE ?? "127.0.0.1",
71111 backendPort,
@@ -111,12 +151,11 @@ async function runProcess( · 39 unmodified lines
111151 async function downloadWithFetch(url: string, destinationPath: string): Promise<void> {
112152 const response = await fetch(url);
113153 if (!response.ok || !response.body) {
114− throw new Error(`Failed downloading Convex backend from ${url} (${response.status})`);
154+ throw new Error(`Failed downloading ${url} (${response.status})`);
115155 }
116156
117157 const totalBytes = Number(response.headers.get("content-length") ?? "0");
118158 const totalMb = totalBytes > 0 ? (totalBytes / (1024 * 1024)).toFixed(1) : null;
119−
120159 const file = await fs.open(destinationPath, "w");
121160 let downloadedBytes = 0;
122161 let nextPercentLog = 10;
@@ -135,7 +174,9 @@ async function downloadWithFetch(url: string, destinationPath: string): Promise< · 12 unmodified lines
135174 if (totalBytes > 0) {
136175 const percent = Math.floor((downloadedBytes / totalBytes) * 100);
137176 if (percent >= nextPercentLog) {
138− console.log(`[executor] download progress ${percent}% (${(downloadedBytes / (1024 * 1024)).toFixed(1)}MB/${totalMb}MB)`);
177+ console.log(
178+ `[executor] download progress ${percent}% (${(downloadedBytes / (1024 * 1024)).toFixed(1)}MB/${totalMb}MB)`,
179+ );
139180 nextPercentLog += 10;
140181 }
141182 } else {
@@ -187,7 +228,16 @@ async function extractZipArchive(archivePath: string, destinationDir: string): P · 45 unmodified lines
187228 stdin: "ignore",
188229 });
189230 if (python.exitCode !== 0) {
190− throw new Error("Failed to extract Convex backend archive. Install unzip or python3.");
231+ throw new Error("Failed to extract zip archive. Install unzip or python3.");
232+ }
233+}
234+
235+async function extractTarArchive(archivePath: string, destinationDir: string): Promise<void> {
236+ const untar = await runProcess("tar", ["-xzf", archivePath, "-C", destinationDir], {
237+ stdin: "ignore",
238+ });
239+ if (untar.exitCode !== 0) {
240+ throw new Error(`Failed to extract tar archive: ${archivePath}`);
191241 }
192242 }
193243
@@ -199,6 +249,13 @@ function runtimeInfo(): ManagedRuntimeInfo { · 5 unmodified lines
199249 const dbPath = path.join(rootDir, "convex-data", "convex_local_backend.sqlite3");
200250 const storageDir = path.join(rootDir, "convex-data", "storage");
201251 const configPath = path.join(rootDir, "convex-backend.json");
252+ const nodeDir = path.join(rootDir, nodeDirectoryName());
253+ const nodeBin = path.join(nodeDir, "bin", "node");
254+ const npmPrefix = path.join(rootDir, "npm");
255+ const npmBin = path.join(nodeDir, "bin", npmBinaryName());
256+ const convexCliEntry = path.join(npmPrefix, "node_modules", "convex", "bin", "main.js");
257+ const webDir = path.join(rootDir, "web");
258+ const webArtifact = webAssetName();
202259
203260 return {
204261 rootDir,
@@ -210,6 +267,15 @@ function runtimeInfo(): ManagedRuntimeInfo { · 5 unmodified lines
210267 storageDir,
211268 configPath,
212269 config: defaultConfig(),
270+ nodeDir,
271+ nodeBin,
272+ npmBin,
273+ npmPrefix,
274+ convexCliEntry,
275+ webDir,
276+ webServerEntry: path.join(webDir, "server.js"),
277+ webArtifactName: webArtifact,
278+ webDownloadUrl: `https://github.com/${EXECUTOR_RELEASE_REPO}/releases/latest/download/${webArtifact}`,
213279 };
214280 }
215281
@@ -217,6 +283,11 @@ async function ensureConfig(info: ManagedRuntimeInfo): Promise<ManagedRuntimeCon · 1 unmodified line
217283 if (await pathExists(info.configPath)) {
218284 const raw = await fs.readFile(info.configPath, "utf8");
219285 const parsed = JSON.parse(raw) as ManagedRuntimeConfig;
286+ if (parsed.instanceName === "executor-local") {
287+ parsed.instanceName = "anonymous-executor";
288+ await fs.writeFile(info.configPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
289+ console.log("[executor] migrated instanceName from executor-local to anonymous-executor");
290+ }
220291 return parsed;
221292 }
222293
@@ -254,6 +325,259 @@ async function ensureBackendBinary(info: ManagedRuntimeInfo): Promise<void> { · 31 unmodified lines
254325 }
255326 }
256327
328+async function ensureNodeRuntime(info: ManagedRuntimeInfo): Promise<void> {
329+ if (await pathExists(info.nodeBin)) {
330+ return;
331+ }
332+
333+ const archiveName = nodeArchiveName();
334+ const archiveUrl = `https://nodejs.org/dist/v${NODE_VERSION}/${archiveName}`;
335+
336+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "executor-node-runtime-"));
337+ const archivePath = path.join(tempDir, archiveName);
338+ try {
339+ await fs.mkdir(info.rootDir, { recursive: true });
340+ console.log(`[executor] downloading managed Node runtime (${archiveName})`);
341+ await downloadArchive(archiveUrl, archivePath);
342+
343+ console.log("[executor] extracting managed Node runtime");
344+ await extractTarArchive(archivePath, info.rootDir);
345+ } finally {
346+ await fs.rm(tempDir, { recursive: true, force: true });
347+ }
348+
349+ if (!(await pathExists(info.nodeBin))) {
350+ throw new Error(`Node runtime install incomplete. Expected node executable at ${info.nodeBin}`);
351+ }
352+}
353+
354+async function ensureConvexCliRuntime(info: ManagedRuntimeInfo): Promise<void> {
355+ if (await pathExists(info.convexCliEntry)) {
356+ return;
357+ }
358+
359+ await ensureNodeRuntime(info);
360+ await fs.mkdir(info.npmPrefix, { recursive: true });
361+
362+ const env = {
363+ ...process.env,
364+ PATH: `${path.dirname(info.nodeBin)}:${process.env.PATH ?? ""}`,
365+ };
366+
367+ console.log(`[executor] installing managed Convex CLI (${CONVEX_CLI_VERSION})`);
368+ const install = await runProcess(
369+ info.npmBin,
370+ [
371+ "install",
372+ "--prefix",
373+ info.npmPrefix,
374+ "--no-audit",
375+ "--no-fund",
376+ "--loglevel",
377+ "error",
378+ `convex@${CONVEX_CLI_VERSION}`,
379+ ],
380+ { env },
381+ );
382+
383+ if (install.exitCode !== 0 || !(await pathExists(info.convexCliEntry))) {
384+ throw new Error("Failed to install managed Convex CLI runtime.");
385+ }
386+}
387+
388+async function generateSelfHostedAdminKey(info: ManagedRuntimeInfo): Promise<string> {
389+ const response = await fetch("https://api.convex.dev/api/local_deployment/generate_admin_key", {
390+ method: "POST",
391+ headers: {
392+ "Content-Type": "application/json",
393+ "Convex-Client": CONVEX_CLIENT_HEADER,
394+ },
395+ body: JSON.stringify({
396+ instanceName: info.config.instanceName,
397+ instanceSecret: info.config.instanceSecret,
398+ }),
399+ });
400+
401+ if (!response.ok) {
402+ const text = await response.text();
403+ throw new Error(`Failed generating self-hosted admin key: ${text || response.statusText}`);
404+ }
405+
406+ const parsed = (await response.json()) as { adminKey?: string };
407+ if (!parsed.adminKey) {
408+ throw new Error("Convex admin key generation did not return an admin key.");
409+ }
410+
411+ return parsed.adminKey;
412+}
413+
414+async function findProjectDir(): Promise<string | null> {
415+ const candidates = [
416+ Bun.env.EXECUTOR_PROJECT_DIR,
417+ process.cwd(),
418+ path.resolve(import.meta.dir, ".."),
419+ ].filter((value): value is string => Boolean(value && value.trim().length > 0));
420+
421+ for (const candidate of candidates) {
422+ const convexDir = path.join(candidate, "convex");
423+ const convexConfig = path.join(convexDir, "convex.config.ts");
424+ const convexJson = path.join(candidate, "convex.json");
425+ if ((await pathExists(convexDir)) && (await pathExists(convexConfig)) && (await pathExists(convexJson))) {
426+ return candidate;
427+ }
428+ }
429+
430+ return null;
431+}
432+
433+async function writeBootstrapEnvFile(info: ManagedRuntimeInfo, adminKey: string): Promise<string> {
434+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "executor-convex-env-"));
435+ const filePath = path.join(tempDir, "selfhost.env");
436+ const contents = [
437+ `CONVEX_SELF_HOSTED_URL=http://${info.config.hostInterface}:${info.config.backendPort}`,
438+ `CONVEX_SELF_HOSTED_ADMIN_KEY=${adminKey}`,
439+ ].join("\n");
440+ await fs.writeFile(filePath, `${contents}\n`, "utf8");
441+ return filePath;
442+}
443+
444+async function runManagedConvexCli(
445+ info: ManagedRuntimeInfo,
446+ projectDir: string,
447+ args: string[],
448+ envFilePath: string,
449+ options?: { stdout?: "inherit" | "pipe"; stderr?: "inherit" | "pipe" },
450+): Promise<{ exitCode: number; stdout: string; stderr: string }> {
451+ const env = {
452+ ...process.env,
453+ PATH: `${path.dirname(info.nodeBin)}:${process.env.PATH ?? ""}`,
454+ CONVEX_DEPLOYMENT: "",
455+ CONVEX_URL: "",
456+ CONVEX_SITE_URL: "",
457+ };
458+
459+ return await runProcess(
460+ info.nodeBin,
461+ [info.convexCliEntry, ...args, "--env-file", envFilePath],
462+ {
463+ cwd: projectDir,
464+ env,
465+ stdout: options?.stdout ?? "inherit",
466+ stderr: options?.stderr ?? "inherit",
467+ },
468+ );
469+}
470+
471+async function waitForBackendReady(info: ManagedRuntimeInfo, timeoutMs = 30_000): Promise<void> {
472+ const target = `http://${info.config.hostInterface}:${info.config.backendPort}/instance_name`;
473+ const started = Date.now();
474+
475+ while (Date.now() - started < timeoutMs) {
476+ try {
477+ const response = await fetch(target);
478+ if (response.ok) {
479+ return;
480+ }
481+ } catch {
482+ // still starting
483+ }
484+ await Bun.sleep(500);
485+ }
486+
487+ throw new Error("Timed out waiting for local Convex backend to become ready.");
488+}
489+
490+async function ensureProjectBootstrapped(info: ManagedRuntimeInfo): Promise<void> {
491+ if (Bun.env.EXECUTOR_SKIP_BOOTSTRAP === "1") {
492+ return;
493+ }
494+
495+ const projectDir = await findProjectDir();
496+ if (!projectDir) {
497+ console.log("[executor] no local Convex project found; skipping function bootstrap");
498+ return;
499+ }
500+
501+ await ensureConvexCliRuntime(info);
502+ const adminKey = await generateSelfHostedAdminKey(info);
503+ const envFilePath = await writeBootstrapEnvFile(info, adminKey);
504+
505+ try {
506+ const check = await runManagedConvexCli(
507+ info,
508+ projectDir,
509+ ["run", "app:getClientConfig"],
510+ envFilePath,
511+ { stdout: "pipe", stderr: "pipe" },
512+ );
513+
514+ if (check.exitCode === 0) {
515+ console.log("[executor] Convex functions already bootstrapped");
516+ return;
517+ }
518+
519+ console.log("[executor] bootstrapping Convex functions to local backend");
520+ const deploy = await runManagedConvexCli(
521+ info,
522+ projectDir,
523+ ["dev", "--once", "--typecheck", "disable", "--codegen", "disable"],
524+ envFilePath,
525+ );
526+
527+ if (deploy.exitCode !== 0) {
528+ throw new Error("Convex bootstrap failed while deploying local functions.");
529+ }
530+ } finally {
531+ await fs.rm(path.dirname(envFilePath), { recursive: true, force: true });
532+ }
533+}
534+
535+async function ensureWebBundle(info: ManagedRuntimeInfo): Promise<void> {
536+ if (await pathExists(info.webServerEntry)) {
537+ return;
538+ }
539+
540+ const legacyWebDir = path.join(os.homedir(), ".executor", "web");
541+ const legacyWebEntry = path.join(legacyWebDir, "server.js");
542+ if (await pathExists(legacyWebEntry)) {
543+ console.log(`[executor] using existing legacy web bundle at ${legacyWebDir}`);
544+ await fs.rm(info.webDir, { recursive: true, force: true });
545+ await fs.mkdir(path.dirname(info.webDir), { recursive: true });
546+ await fs.cp(legacyWebDir, info.webDir, { recursive: true });
547+ return;
548+ }
549+
550+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "executor-web-bundle-"));
551+ const archivePath = path.join(tempDir, info.webArtifactName);
552+ const localFallbackArchive = path.resolve(import.meta.dir, "..", "dist", "release", info.webArtifactName);
553+
554+ try {
555+ console.log(`[executor] downloading managed web bundle (${info.webArtifactName})`);
556+ try {
557+ await downloadArchive(info.webDownloadUrl, archivePath);
558+ } catch (error) {
559+ if (await pathExists(localFallbackArchive)) {
560+ console.log(`[executor] release web bundle unavailable, using local artifact ${localFallbackArchive}`);
561+ await fs.copyFile(localFallbackArchive, archivePath);
562+ } else {
563+ throw error;
564+ }
565+ }
566+
567+ await fs.rm(info.webDir, { recursive: true, force: true });
568+ await fs.mkdir(info.webDir, { recursive: true });
569+
570+ console.log("[executor] extracting managed web bundle");
571+ await extractTarArchive(archivePath, info.webDir);
572+ } finally {
573+ await fs.rm(tempDir, { recursive: true, force: true });
574+ }
575+
576+ if (!(await pathExists(info.webServerEntry))) {
577+ throw new Error(`Web bundle install incomplete. Expected server entry at ${info.webServerEntry}`);
578+ }
579+}
580+
257581 function backendArgs(info: ManagedRuntimeInfo, additionalArgs: string[]): string[] {
258582 const cfg = info.config;
259583 return [
@@ -286,7 +610,60 @@ export async function ensureManagedRuntime(): Promise<ManagedRuntimeInfo> { · 26 unmodified lines
286610
287611 export async function runManagedBackend(args: string[]): Promise<number> {
288612 const info = await ensureManagedRuntime();
289− const proc = await runProcess(info.backendBinary, backendArgs(info, args));
613+ const env = {
614+ ...process.env,
615+ PATH: `${path.dirname(info.nodeBin)}:${process.env.PATH ?? ""}`,
616+ };
617+
618+ const proc = Bun.spawn([info.backendBinary, ...backendArgs(info, args)], {
619+ env,
620+ stdin: "inherit",
621+ stdout: "inherit",
622+ stderr: "inherit",
623+ });
624+
625+ if (args.length === 0) {
626+ try {
627+ await waitForBackendReady(info);
628+ await ensureProjectBootstrapped(info);
629+ } catch (error) {
630+ const message = error instanceof Error ? error.message : String(error);
631+ console.warn(`[executor] bootstrap skipped: ${message}`);
632+ }
633+ }
634+
635+ return await proc.exited;
636+}
637+
638+export async function runManagedWeb(options: { port?: number } = {}): Promise<number> {
639+ const info = await ensureManagedRuntime();
640+ try {
641+ await waitForBackendReady(info, 3_000);
642+ await ensureProjectBootstrapped(info);
643+ } catch {
644+ // Backend not running yet; web can still start and retry queries later.
645+ }
646+ await ensureNodeRuntime(info);
647+ await ensureWebBundle(info);
648+
649+ const mcpPort = Number(Bun.env.EXECUTOR_MCP_GATEWAY_PORT ?? 5313);
650+ const webPort = options.port ?? Number(Bun.env.EXECUTOR_WEB_PORT ?? 5312);
651+ const host = Bun.env.EXECUTOR_WEB_INTERFACE ?? "127.0.0.1";
652+
653+ const env = {
654+ ...process.env,
655+ NODE_ENV: "production",
656+ PORT: String(webPort),
657+ HOSTNAME: host,
658+ CONVEX_URL: process.env.CONVEX_URL ?? `http://${info.config.hostInterface}:${info.config.backendPort}`,
659+ CONVEX_SITE_URL: process.env.CONVEX_SITE_URL ?? `http://${info.config.hostInterface}:${info.config.siteProxyPort}`,
660+ NEXT_PUBLIC_LOCAL_MCP_ORIGIN: process.env.NEXT_PUBLIC_LOCAL_MCP_ORIGIN ?? `http://localhost:${mcpPort}`,
661+ };
662+
663+ const proc = await runProcess(info.nodeBin, [info.webServerEntry], {
664+ cwd: info.webDir,
665+ env,
666+ });
290667 return proc.exitCode;
291668 }
292669
executor/lib/mcp-gateway-oauth-sdk.test.ts+21−10
@@ -2,7 +2,8 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test"; · 1 unmodified line
22 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
44 import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
5−import type { OAuthClientProvider, OAuthClientMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
5+import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
6+import type { OAuthClientMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
67 import { AnonymousOAuthServer, OAuthBadRequest } from "./anonymous-oauth";
78 import { handleMcpRequest, type McpWorkspaceContext } from "./mcp_server";
89 import type { AnonymousContext, CreateTaskInput, TaskRecord, ToolDescriptor } from "./types";
@@ -130,7 +131,7 @@ class TestOAuthClientProvider implements OAuthClientProvider { · 121 unmodified lines
130131
131132 get clientMetadata(): OAuthClientMetadata {
132133 return {
133− redirect_uris: [new URL("http://localhost:0/callback")],
134+ redirect_uris: ["http://localhost:0/callback"],
134135 client_name: "mcp-oauth-sdk-test",
135136 };
136137 }
@@ -216,7 +217,8 @@ beforeAll(async () => { · 79 unmodified lines
216217 if (url.pathname === "/register" && request.method === "POST") {
217218 const body = await request.json();
218219 try {
219− return Response.json(oauthServer.registerClient(body as any), { status: 201 });
220+ const reg = await oauthServer.registerClient(body as any);
221+ return Response.json(reg, { status: 201 });
220222 } catch (e) {
221223 if (e instanceof OAuthBadRequest) {
222224 return Response.json({ error: "invalid_client_metadata", error_description: e.message }, { status: 400 });
@@ -227,7 +229,7 @@ beforeAll(async () => { · 4 unmodified lines
227229
228230 if (url.pathname === "/authorize" && request.method === "GET") {
229231 try {
230− const { redirectTo } = oauthServer.authorize(url.searchParams);
232+ const { redirectTo } = await oauthServer.authorize(url.searchParams);
231233 return Response.redirect(redirectTo, 302);
232234 } catch (e) {
233235 if (e instanceof OAuthBadRequest) {
@@ -350,15 +352,24 @@ describe("MCP SDK Client OAuth E2E", () => { · 116 unmodified lines
350352 // Step 4: Complete the auth flow — SDK exchanges code for token
351353 await transport.finishAuth(code);
352354
353− // Step 5: Now connect succeeds
354− await client.connect(transport);
355+ // Step 5: Create a new transport + client (the old transport was already started
356+ // by the first connect call — the SDK doesn't allow re-starting it).
357+ // The provider now has tokens stored, so this connection will succeed.
358+ const transport2 = new StreamableHTTPClientTransport(mcpUrl, {
359+ authProvider: provider,
360+ });
361+ const client2 = new Client(
362+ { name: "oauth-e2e-test", version: "1.0.0" },
363+ { capabilities: {} },
364+ );
365+ await client2.connect(transport2);
355366
356367 // Step 6: Make a tool call — this uses the Bearer token automatically
357− const tools = await client.listTools();
368+ const tools = await client2.listTools();
358369 expect(tools.tools.length).toBeGreaterThan(0);
359370 expect(tools.tools[0].name).toBe("run_code");
360371
361− const result = (await client.callTool({
372+ const result = (await client2.callTool({
362373 name: "run_code",
363374 arguments: {
364375 code: "return 42",
@@ -379,7 +390,7 @@ describe("MCP SDK Client OAuth E2E", () => { · 14 unmodified lines
379390 expect(typeof structured?.actorId).toBe("string");
380391
381392 // Cleanup
382− await transport.close().catch(() => {});
383− await client.close().catch(() => {});
393+ await transport2.close().catch(() => {});
394+ await client2.close().catch(() => {});
384395 });
385396 });
executor/lib/mcp-gateway-oauth.test.ts+4−2
@@ -85,7 +85,7 @@ beforeAll(async () => { · 84 unmodified lines
8585 if (url.pathname === "/register" && request.method === "POST") {
8686 const body = await request.json();
8787 try {
88− const reg = oauthServer.registerClient(body as any);
88+ const reg = await oauthServer.registerClient(body as any);
8989 return Response.json(reg, { status: 201 });
9090 } catch (error) {
9191 if (error instanceof OAuthBadRequest) {
@@ -100,7 +100,7 @@ beforeAll(async () => { · 8 unmodified lines
100100
101101 if (url.pathname === "/authorize" && request.method === "GET") {
102102 try {
103− const { redirectTo } = oauthServer.authorize(url.searchParams);
103+ const { redirectTo } = await oauthServer.authorize(url.searchParams);
104104 return Response.redirect(redirectTo, 302);
105105 } catch (error) {
106106 if (error instanceof OAuthBadRequest) {
@@ -293,6 +293,7 @@ describe("MCP Gateway OAuth Integration", () => { · 186 unmodified lines
293293 body: new URLSearchParams({
294294 grant_type: "authorization_code",
295295 code,
296+ client_id,
296297 redirect_uri: "http://localhost:9999/callback",
297298 code_verifier: codeVerifier,
298299 }).toString(),
@@ -413,6 +414,7 @@ describe("MCP Gateway OAuth Integration", () => { · 114 unmodified lines
413414 body: new URLSearchParams({
414415 grant_type: "authorization_code",
415416 code,
417+ client_id,
416418 redirect_uri: "http://localhost:9999/callback",
417419 code_verifier: codeVerifier,
418420 }).toString(),
executor/mcp-gateway.ts+11−5
@@ -3,6 +3,7 @@ import { createRemoteJWKSet, jwtVerify } from "jose"; · 2 unmodified lines
33 import { api } from "./convex/_generated/api";
44 import { handleMcpRequest, type McpWorkspaceContext } from "./lib/mcp_server";
55 import { AnonymousOAuthServer, OAuthBadRequest } from "./lib/anonymous-oauth";
6+import { ConvexOAuthStorage } from "./lib/convex-oauth-storage";
67 import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescriptor } from "./lib/types";
78 import type { Id } from "./convex/_generated/dataModel";
89
@@ -44,7 +45,12 @@ function loadConfigFromEnv(): void { · 35 unmodified lines
4445 }
4546
4647 async function initAnonymousOAuth(issuer: string): Promise<void> {
47− anonOAuth = new AnonymousOAuthServer({ issuer });
48+ const internalSecret = Bun.env.EXECUTOR_INTERNAL_TOKEN;
49+ const storage = convexUrl && internalSecret
50+ ? new ConvexOAuthStorage(convexUrl, internalSecret)
51+ : undefined;
52+
53+ anonOAuth = new AnonymousOAuthServer({ issuer, storage });
4854 await anonOAuth.init();
4955 }
5056
@@ -381,7 +387,7 @@ async function handleRegister(request: Request): Promise<Response> { · 330 unmodified lines
381387 }
382388
383389 try {
384− const registration = anonOAuth.registerClient(body as any);
390+ const registration = await anonOAuth.registerClient(body as any);
385391 return Response.json(registration, { status: 201 });
386392 } catch (error) {
387393 if (error instanceof OAuthBadRequest) {
@@ -394,14 +400,14 @@ async function handleRegister(request: Request): Promise<Response> { · 6 unmodified lines
394400 }
395401 }
396402
397−function handleAuthorize(request: Request): Response {
403+async function handleAuthorize(request: Request): Promise<Response> {
398404 if (!anonOAuth) {
399405 return Response.json({ error: "Anonymous OAuth server not initialized" }, { status: 500 });
400406 }
401407
402408 const url = new URL(request.url);
403409 try {
404− const { redirectTo } = anonOAuth.authorize(url.searchParams);
410+ const { redirectTo } = await anonOAuth.authorize(url.searchParams);
405411 return Response.redirect(redirectTo, 302);
406412 } catch (error) {
407413 if (error instanceof OAuthBadRequest) {
@@ -501,7 +507,7 @@ export async function startMcpGateway( · 93 unmodified lines
501507 }
502508
503509 if (url.pathname === "/authorize" && request.method === "GET") {
504− return handleAuthorize(request);
510+ return await handleAuthorize(request);
505511 }
506512
507513 if (url.pathname === "/token" && request.method === "POST") {
executor/package.json+1−0
@@ -7,6 +7,7 @@ · 6 unmodified lines
77 "up": "bun run executor.ts up",
88 "backend": "bun run executor.ts backend",
99 "convex": "bun run executor.ts backend",
10+ "web": "bun run executor.ts web",
1011 "gateway": "bun run executor.ts gateway",
1112 "deploy": "bunx convex deploy",
1213 "codegen": "bunx convex codegen",
executor/scripts/build-release.ts+77−0
@@ -17,6 +17,16 @@ const targets: ReleaseTarget[] = [ · 16 unmodified lines
1717 { platform: "darwin", arch: "arm64", bunTarget: "bun-darwin-arm64" },
1818 ];
1919
20+function hostPlatformArch(): { platform: "linux" | "darwin"; arch: "x64" | "arm64" } {
21+ if (process.platform !== "linux" && process.platform !== "darwin") {
22+ throw new Error(`Unsupported host platform for web artifact build: ${process.platform}`);
23+ }
24+ if (process.arch !== "x64" && process.arch !== "arm64") {
25+ throw new Error(`Unsupported host architecture for web artifact build: ${process.arch}`);
26+ }
27+ return { platform: process.platform, arch: process.arch };
28+}
29+
2030 function archiveName(platform: ReleaseTarget["platform"], arch: ReleaseTarget["arch"]): string {
2131 return `executor-${platform}-${arch}.tar.gz`;
2232 }
@@ -39,6 +49,71 @@ async function runArchiveCommand(command: string[], cwd?: string): Promise<void> · 16 unmodified lines
3949 }
4050 }
4151
52+async function pathExists(filePath: string): Promise<boolean> {
53+ try {
54+ await fs.access(filePath);
55+ return true;
56+ } catch {
57+ return false;
58+ }
59+}
60+
61+async function runCommand(command: string[], options: { cwd?: string; env?: Record<string, string | undefined> } = {}): Promise<void> {
62+ const proc = Bun.spawn(command, {
63+ cwd: options.cwd,
64+ env: options.env,
65+ stdin: "inherit",
66+ stdout: "inherit",
67+ stderr: "inherit",
68+ });
69+ const code = await proc.exited;
70+ if (code !== 0) {
71+ throw new Error(`Command failed (${code}): ${command.join(" ")}`);
72+ }
73+}
74+
75+async function buildWebArtifact(rootDir: string, releaseDir: string, checksums: string[]): Promise<void> {
76+ const host = hostPlatformArch();
77+ const webAppDir = path.join(rootDir, "apps", "web");
78+ const webArtifactName = `executor-web-${host.platform}-${host.arch}.tar.gz`;
79+ const archivePath = path.join(releaseDir, webArtifactName);
80+
81+ const webBuildEnv = {
82+ ...process.env,
83+ EXECUTOR_WEB_CONVEX_URL: "http://127.0.0.1:5410",
84+ NEXT_PUBLIC_LOCAL_MCP_ORIGIN: "http://localhost:5313",
85+ };
86+
87+ await runCommand(["bunx", "next", "build"], {
88+ cwd: webAppDir,
89+ env: webBuildEnv,
90+ });
91+
92+ const standaloneAppRoot = path.join(webAppDir, ".next", "standalone", "executor", "apps", "web");
93+ const staticRoot = path.join(webAppDir, ".next", "static");
94+ const publicRoot = path.join(webAppDir, "public");
95+ const stageRoot = path.join(releaseDir, `executor-web-${host.platform}-${host.arch}`);
96+
97+ if (!(await pathExists(standaloneAppRoot))) {
98+ throw new Error(`Missing standalone output at ${standaloneAppRoot}. Ensure next build output is standalone.`);
99+ }
100+
101+ await fs.rm(stageRoot, { recursive: true, force: true });
102+ await fs.mkdir(stageRoot, { recursive: true });
103+
104+ await fs.cp(standaloneAppRoot, stageRoot, { recursive: true });
105+ await fs.mkdir(path.join(stageRoot, ".next"), { recursive: true });
106+ await fs.cp(staticRoot, path.join(stageRoot, ".next", "static"), { recursive: true });
107+ if (await pathExists(publicRoot)) {
108+ await fs.cp(publicRoot, path.join(stageRoot, "public"), { recursive: true });
109+ }
110+
111+ await runArchiveCommand(["tar", "-czf", archivePath, "-C", stageRoot, "."]);
112+ const digest = await sha256(archivePath);
113+ checksums.push(`${digest} ${path.basename(archivePath)}`);
114+ console.log(`built ${webArtifactName}`);
115+}
116+
42117 async function main(): Promise<void> {
43118 const rootDir = path.resolve(import.meta.dir, "..");
44119 const releaseDir = path.join(rootDir, "dist", "release");
@@ -78,6 +153,8 @@ async function main(): Promise<void> { · 33 unmodified lines
78153 console.log(`built ${path.basename(archivePath)}`);
79154 }
80155
156+ await buildWebArtifact(rootDir, releaseDir, checksums);
157+
81158 await Bun.write(path.join(releaseDir, "checksums.txt"), `${checksums.join("\n")}\n`);
82159 console.log(`wrote ${path.join("dist", "release", "checksums.txt")}`);
83160 }
executor/uninstall+68−0
@@ -0,0 +1,68 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}"
5+RUNTIME_DIR="${EXECUTOR_RUNTIME_DIR:-$HOME/.executor/runtime}"
6+LEGACY_WEB_DIR="${EXECUTOR_LEGACY_WEB_DIR:-$HOME/.executor/web}"
7+EXECUTOR_HOME="${EXECUTOR_HOME_DIR:-$HOME/.executor}"
8+ASSUME_YES="0"
9+
10+usage() {
11+ cat <<EOF
12+Executor uninstaller
13+
14+Usage:
15+ bash executor/uninstall [--yes]
16+
17+Options:
18+ -y, --yes Skip confirmation prompt
19+ -h, --help Show this help
20+EOF
21+}
22+
23+while [[ $# -gt 0 ]]; do
24+ case "$1" in
25+ -y|--yes)
26+ ASSUME_YES="1"
27+ shift
28+ ;;
29+ -h|--help)
30+ usage
31+ exit 0
32+ ;;
33+ *)
34+ echo "Unknown option: $1"
35+ exit 1
36+ ;;
37+ esac
38+done
39+
40+if [[ "$ASSUME_YES" != "1" ]]; then
41+ echo "This will remove:"
42+ echo " - $INSTALL_DIR/executor"
43+ echo " - $RUNTIME_DIR"
44+ echo " - $LEGACY_WEB_DIR"
45+ echo ""
46+ read -r -p "Continue? [y/N] " response
47+ if [[ ! "$response" =~ ^[Yy]$ ]]; then
48+ echo "Cancelled."
49+ exit 0
50+ fi
51+fi
52+
53+rm -f "$INSTALL_DIR/executor"
54+rm -rf "$RUNTIME_DIR"
55+rm -rf "$LEGACY_WEB_DIR"
56+
57+if [[ -d "$INSTALL_DIR" ]]; then
58+ rmdir "$INSTALL_DIR" 2>/dev/null || true
59+fi
60+
61+if [[ -d "$EXECUTOR_HOME" ]]; then
62+ rmdir "$EXECUTOR_HOME" 2>/dev/null || true
63+fi
64+
65+echo "Executor uninstall complete."
66+echo ""
67+echo "If you previously added PATH manually, remove this line from your shell rc:"
68+echo " export PATH=$INSTALL_DIR:\$PATH"