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
24# ── Local port overrides (optional) ──24# ── Local port overrides (optional) ──
25# EXECUTOR_BACKEND_PORT=541025# EXECUTOR_BACKEND_PORT=5410
26# EXECUTOR_BACKEND_SITE_PORT=541126# EXECUTOR_BACKEND_SITE_PORT=5411
27# EXECUTOR_WEB_PORT=5312
27# EXECUTOR_MCP_GATEWAY_PORT=531328# EXECUTOR_MCP_GATEWAY_PORT=5313
28# NEXT_PUBLIC_LOCAL_MCP_ORIGIN=http://localhost:431329# NEXT_PUBLIC_LOCAL_MCP_ORIGIN=http://localhost:4313
29 30
executor/README.md+6−0
@@ -11,6 +11,7 @@ curl -fsSL https://executor.sh/install | bash · 10 unmodified lines
11The installed `executor` binary bootstraps and manages its own runtime under `~/.executor/runtime`, including:11The installed `executor` binary bootstraps and manages its own runtime under `~/.executor/runtime`, including:
12 12
13- `convex-local-backend` (downloaded from Convex precompiled releases)13- `convex-local-backend` (downloaded from Convex precompiled releases)
14- packaged Executor web UI bundle
14- persisted local backend config (`instanceName`, `instanceSecret`, and ports)15- persisted local backend config (`instanceName`, `instanceSecret`, and ports)
15- local SQLite + file storage data16- local SQLite + file storage data
16 17
@@ -19,11 +20,15 @@ Common commands: · 2 unmodified lines
19```bash20```bash
20executor doctor21executor doctor
21executor up22executor up
23executor web
22executor backend --help24executor backend --help
23executor gateway25executor gateway
26bash executor/uninstall --yes
24```27```
25 28
26`executor up` runs the managed `convex-local-backend` binary directly without requiring users to install Bun, Node, or Convex.29`executor up` runs the managed `convex-local-backend` binary directly without requiring users to install Bun, Node, or Convex.
30On 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`.
27 32
28## Local development from source33## Local development from source
29 34
@@ -84,6 +89,7 @@ Artifacts are written to `dist/release/`. · 54 unmodified lines
84- `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.89- `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.
85- Local source-dev defaults: web `http://localhost:4312`, MCP gateway `http://localhost:4313/mcp`.90- Local source-dev defaults: web `http://localhost:4312`, MCP gateway `http://localhost:4313/mcp`.
86- Installed binary default for `executor gateway`: `http://localhost:5313/mcp`.91- 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.
87 93
88## Credential providers94## Credential providers
89 95
executor/apps/web/next.config.ts+2−1
@@ -15,11 +15,12 @@ const appShellRewriteExclusions = [ · 14 unmodified lines
15].join("|");15].join("|");
16 16
17const nextConfig: NextConfig = {17const nextConfig: NextConfig = {
18 output: "standalone",
18 transpilePackages: ["@executor/convex"],19 transpilePackages: ["@executor/convex"],
19 env: {20 env: {
20 // Map canonical env vars to NEXT_PUBLIC_ so they're available client-side.21 // Map canonical env vars to NEXT_PUBLIC_ so they're available client-side.
21 // This lets us keep a single root .env without NEXT_PUBLIC_ prefixes.22 // 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,
23 NEXT_PUBLIC_WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID,24 NEXT_PUBLIC_WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID,
24 NEXT_PUBLIC_STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID,25 NEXT_PUBLIC_STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID,
25 },26 },
executor/convex/_generated/api.d.ts+37−0
@@ -28,6 +28,43 @@ export declare const api: { · 27 unmodified lines
28 any28 any
29 >;29 >;
30 };30 };
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 };
31 app: {68 app: {
32 getClientConfig: FunctionReference<"query", "public", {}, any>;69 getClientConfig: FunctionReference<"query", "public", {}, any>;
33 getCurrentAccount: FunctionReference<70 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
12import { v } from "convex/values";
13import { internal } from "./_generated/api";
14import { mutation, query } from "./_generated/server";
15
16function 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
28export 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
39export 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
59export 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
76export 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
25 process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET,25 process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET,
26);26);
27 27
28const authFunctions: AuthFunctions = internal.auth;28const authFunctions = (internal as Record<string, unknown>).auth as AuthFunctions;
29const workosComponent = (components as Record<string, unknown>).workOSAuthKit;
29 30
30const authKitInstance = workosEnabled31const authKitInstance = workosEnabled
31 ? new AuthKit<DataModel>(components.workOSAuthKit, {32 ? new AuthKit<DataModel>(workosComponent as never, {
32 authFunctions,33 authFunctions,
33 additionalEventTypes: [34 additionalEventTypes: [
34 "organization.created",35 "organization.created",
executor/convex/convex.config.ts+7−1
@@ -5,7 +5,13 @@ import { defineApp } from "convex/server"; · 4 unmodified lines
5 5
6const app = defineApp();6const app = defineApp();
7 7
8app.use(workOSAuthKit);8const workosEnabled = Boolean(
9 process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET,
10);
11
12if (workosEnabled) {
13 app.use(workOSAuthKit);
14}
9app.use(stripe);15app.use(stripe);
10app.use(migrations);16app.use(migrations);
11 17
executor/executor.ts+46−2
@@ -1,7 +1,8 @@
1#!/usr/bin/env bun1#!/usr/bin/env bun
2 2
3import fs from "node:fs/promises";
3import { startMcpGateway } from "./mcp-gateway";4import { startMcpGateway } from "./mcp-gateway";
4import { managedRuntimeDiagnostics, runManagedBackend } from "./lib/managed_runtime";5import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./lib/managed_runtime";
5 6
6function printHelp(): void {7function printHelp(): void {
7 console.log(`Executor CLI8 console.log(`Executor CLI
@@ -10,12 +11,14 @@ Usage: · 2 unmodified lines
10 executor doctor11 executor doctor
11 executor up [backend-args]12 executor up [backend-args]
12 executor backend <args>13 executor backend <args>
14 executor web [--port <number>]
13 executor gateway [--port <number>]15 executor gateway [--port <number>]
14 16
15Commands:17Commands:
16 doctor Bootstrap and verify managed Convex backend runtime18 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
18 backend Pass through arguments to managed convex-local-backend binary20 backend Pass through arguments to managed convex-local-backend binary
21 web Run packaged web UI (default port: 5312)
19 gateway Start Executor MCP gateway (default port: 5313)22 gateway Start Executor MCP gateway (default port: 5313)
20`);23`);
21}24}
@@ -39,6 +42,28 @@ function parsePort(args: string[]): number | undefined { · 17 unmodified lines
39 return port;42 return port;
40}43}
41 44
45async function pathExists(filePath: string): Promise<boolean> {
46 try {
47 await fs.access(filePath);
48 return true;
49 } catch {
50 return false;
51 }
52}
53
54async 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
42async function run(): Promise<void> {67async function run(): Promise<void> {
43 const [command, ...rest] = process.argv.slice(2);68 const [command, ...rest] = process.argv.slice(2);
44 69
@@ -49,11 +74,24 @@ async function run(): Promise<void> { · 4 unmodified lines
49 74
50 if (command === "doctor") {75 if (command === "doctor") {
51 const info = await managedRuntimeDiagnostics();76 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
52 console.log("Managed runtime ready");85 console.log("Managed runtime ready");
53 console.log(` root: ${info.rootDir}`);86 console.log(` root: ${info.rootDir}`);
54 console.log(` backend: ${info.backendVersion} (${info.backendBinary})`);87 console.log(` backend: ${info.backendVersion} (${info.backendBinary})`);
55 console.log(` convex URL: ${info.convexUrl}`);88 console.log(` convex URL: ${info.convexUrl}`);
56 console.log(` convex site: ${info.convexSiteUrl}`);89 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"}`);
57 console.log(` config: ${info.configPath}`);95 console.log(` config: ${info.configPath}`);
58 return;96 return;
59 }97 }
@@ -77,6 +115,12 @@ async function run(): Promise<void> { · 17 unmodified lines
77 return;115 return;
78 }116 }
79 117
118 if (command === "web") {
119 const port = parsePort(rest);
120 const exitCode = await runManagedWeb({ port });
121 process.exit(exitCode);
122 }
123
80 throw new Error(`Unknown command: ${command}`);124 throw new Error(`Unknown command: ${command}`);
81}125}
82 126
executor/install+80−2
@@ -4,11 +4,14 @@ set -euo pipefail · 3 unmodified lines
4APP="executor"4APP="executor"
5REPO="${EXECUTOR_REPO:-RhysSullivan/assistant}"5REPO="${EXECUTOR_REPO:-RhysSullivan/assistant}"
6INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}"6INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}"
7WEB_INSTALL_DIR="${EXECUTOR_WEB_INSTALL_DIR:-$HOME/.executor/runtime/web}"
7REQUESTED_VERSION="${VERSION:-}"8REQUESTED_VERSION="${VERSION:-}"
9RESOLVED_VERSION=""
8BINARY_PATH=""10BINARY_PATH=""
9NO_MODIFY_PATH="0"11NO_MODIFY_PATH="0"
10SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"12SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
11LOCAL_BINARY_CANDIDATE="$SCRIPT_DIR/dist/executor"13LOCAL_BINARY_CANDIDATE="$SCRIPT_DIR/dist/executor"
14LOCAL_WEB_CANDIDATE="$SCRIPT_DIR/dist/release"
12 15
13RED='\033[0;31m'16RED='\033[0;31m'
14MUTED='\033[0;2m'17MUTED='\033[0;2m'
@@ -30,6 +33,7 @@ Options: · 15 unmodified lines
30Environment:33Environment:
31 EXECUTOR_REPO GitHub repository to install from (default: RhysSullivan/assistant)34 EXECUTOR_REPO GitHub repository to install from (default: RhysSullivan/assistant)
32 EXECUTOR_INSTALL_DIR Install directory (default: ~/.executor/bin)35 EXECUTOR_INSTALL_DIR Install directory (default: ~/.executor/bin)
36 EXECUTOR_WEB_INSTALL_DIR Web bundle directory (default: ~/.executor/runtime/web)
33EOF37EOF
34}38}
35 39
@@ -66,6 +70,10 @@ while [[ $# -gt 0 ]]; do · 30 unmodified lines
66 esac70 esac
67done71done
68 72
73if [[ -n "$REQUESTED_VERSION" ]]; then
74 RESOLVED_VERSION="${REQUESTED_VERSION#v}"
75fi
76
69raw_os="$(uname -s)"77raw_os="$(uname -s)"
70case "$raw_os" in78case "$raw_os" in
71 Linux*) os="linux" ;;79 Linux*) os="linux" ;;
@@ -90,16 +98,75 @@ archive_ext="zip" · 18 unmodified lines
90archive_ext="tar.gz"98archive_ext="tar.gz"
91 99
92filename="${APP}-${os}-${arch}.${archive_ext}"100filename="${APP}-${os}-${arch}.${archive_ext}"
101web_filename="executor-web-${os}-${arch}.tar.gz"
93mkdir -p "$INSTALL_DIR"102mkdir -p "$INSTALL_DIR"
103mkdir -p "$WEB_INSTALL_DIR"
94 104
95install_local_binary() {105install_local_binary() {
96 local src="$1"106 local src="$1"
107 local tmp_target
97 if [[ ! -f "$src" ]]; then108 if [[ ! -f "$src" ]]; then
98 echo -e "${RED}Local binary not found: ${src}${NC}"109 echo -e "${RED}Local binary not found: ${src}${NC}"
99 exit 1110 exit 1
100 fi111 fi
101 cp "$src" "$INSTALL_DIR/$APP"112 tmp_target="$INSTALL_DIR/.${APP}.tmp.$$"
102 chmod 755 "$INSTALL_DIR/$APP"113 cp "$src" "$tmp_target"
114 chmod 755 "$tmp_target"
115 mv -f "$tmp_target" "$INSTALL_DIR/$APP"
116}
117
118install_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
139install_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"
103}170}
104 171
105if [[ -n "$BINARY_PATH" ]]; then172if [[ -n "$BINARY_PATH" ]]; then
@@ -113,8 +180,10 @@ else · 7 unmodified lines
113 if [[ -z "$version" ]]; then180 if [[ -z "$version" ]]; then
114 version="latest"181 version="latest"
115 fi182 fi
183 RESOLVED_VERSION="${version#v}"
116 else184 else
117 version="${REQUESTED_VERSION#v}"185 version="${REQUESTED_VERSION#v}"
186 RESOLVED_VERSION="$version"
118 url="https://github.com/${REPO}/releases/download/v${version}/${filename}"187 url="https://github.com/${REPO}/releases/download/v${version}/${filename}"
119 fi188 fi
120 189
@@ -147,6 +216,8 @@ else · 26 unmodified lines
147 fi216 fi
148fi217fi
149 218
219install_web_bundle
220
150if [[ "$NO_MODIFY_PATH" != "1" ]]; then221if [[ "$NO_MODIFY_PATH" != "1" ]]; then
151 shell_name="$(basename "${SHELL:-}")"222 shell_name="$(basename "${SHELL:-}")"
152 case "$shell_name" in223 case "$shell_name" in
@@ -165,7 +236,14 @@ fi · 12 unmodified lines
165 236
166echo ""237echo ""
167echo "Installed to: $INSTALL_DIR/$APP"238echo "Installed to: $INSTALL_DIR/$APP"
239if [[ -f "$WEB_INSTALL_DIR/server.js" ]]; then
240 echo "Web bundle installed to: $WEB_INSTALL_DIR"
241fi
168echo "Run:"242echo "Run:"
169echo " executor doctor"243echo " executor doctor"
170echo " executor up"244echo " executor up"
245echo " executor web # defaults to http://localhost:5312"
171echo " executor gateway # defaults to http://localhost:5313/mcp"246echo " executor gateway # defaults to http://localhost:5313/mcp"
247echo ""
248echo "To uninstall later:"
249echo " bash executor/uninstall --yes"
executor/lib/anonymous-oauth.test.ts+180−38
@@ -1,6 +1,7 @@
1import { describe, test, expect, beforeAll } from "bun:test";1import { describe, test, expect, beforeAll } from "bun:test";
2import {2import {
3 AnonymousOAuthServer,3 AnonymousOAuthServer,
4 InMemoryOAuthStorage,
4 OAuthBadRequest,5 OAuthBadRequest,
5 computeS256Challenge,6 computeS256Challenge,
6} from "./anonymous-oauth";7} from "./anonymous-oauth";
@@ -28,7 +29,7 @@ async function registerAndAuthorize( · 21 unmodified lines
28 server: AnonymousOAuthServer,29 server: AnonymousOAuthServer,
29 redirectUri = "http://localhost:9999/callback",30 redirectUri = "http://localhost:9999/callback",
30) {31) {
31 const client = server.registerClient({32 const client = await server.registerClient({
32 redirect_uris: [redirectUri],33 redirect_uris: [redirectUri],
33 client_name: "test-client",34 client_name: "test-client",
34 });35 });
@@ -45,7 +46,7 @@ async function registerAndAuthorize( · 10 unmodified lines
45 state: "test-state-123",46 state: "test-state-123",
46 });47 });
47 48
48 const { redirectTo } = server.authorize(params);49 const { redirectTo } = await server.authorize(params);
49 const redirectUrl = new URL(redirectTo);50 const redirectUrl = new URL(redirectTo);
50 const code = redirectUrl.searchParams.get("code")!;51 const code = redirectUrl.searchParams.get("code")!;
51 52
@@ -58,9 +59,14 @@ async function registerAndAuthorize( · 6 unmodified lines
58 59
59describe("AnonymousOAuthServer", () => {60describe("AnonymousOAuthServer", () => {
60 let server: AnonymousOAuthServer;61 let server: AnonymousOAuthServer;
62 let storage: InMemoryOAuthStorage;
61 63
62 beforeAll(async () => {64 beforeAll(async () => {
63 server = createServer();65 storage = new InMemoryOAuthStorage();
66 server = new AnonymousOAuthServer({
67 issuer: "http://localhost:3003",
68 storage,
69 });
64 await server.init();70 await server.init();
65 });71 });
66 72
@@ -106,8 +112,8 @@ describe("AnonymousOAuthServer", () => { · 39 unmodified lines
106 // ── Client Registration ─────────────────────────────────────────────────112 // ── Client Registration ─────────────────────────────────────────────────
107 113
108 describe("registerClient", () => {114 describe("registerClient", () => {
109 test("registers a client and returns a client_id", () => {115 test("registers a client and returns a client_id", async () => {
110 const reg = server.registerClient({116 const reg = await server.registerClient({
111 redirect_uris: ["http://localhost:9999/callback"],117 redirect_uris: ["http://localhost:9999/callback"],
112 client_name: "my-mcp-client",118 client_name: "my-mcp-client",
113 });119 });
@@ -118,22 +124,28 @@ describe("AnonymousOAuthServer", () => { · 4 unmodified lines
118 expect(reg.created_at).toBeGreaterThan(0);124 expect(reg.created_at).toBeGreaterThan(0);
119 });125 });
120 126
121 test("rejects registration without redirect_uris", () => {127 test("rejects registration without redirect_uris", async () => {
122 expect(() => server.registerClient({} as any)).toThrow(OAuthBadRequest);128 await expect(server.registerClient({} as any)).rejects.toThrow(OAuthBadRequest);
123 });129 });
124 130
125 test("rejects registration with empty redirect_uris", () => {131 test("rejects registration with empty redirect_uris", async () => {
126 expect(() => server.registerClient({ redirect_uris: [] })).toThrow(OAuthBadRequest);132 await expect(server.registerClient({ redirect_uris: [] })).rejects.toThrow(OAuthBadRequest);
127 });133 });
128 134
129 test("rejects registration with invalid redirect_uri entries", () => {135 test("rejects registration with invalid redirect_uri entries", async () => {
130 expect(() => server.registerClient({ redirect_uris: [""] })).toThrow(OAuthBadRequest);136 await expect(server.registerClient({ redirect_uris: [""] })).rejects.toThrow(OAuthBadRequest);
131 });137 });
132 138
133 test("increments client count", () => {139 test("rejects registration with non-URL redirect_uri", async () => {
134 const before = server.getClientCount();140 await expect(
135 server.registerClient({ redirect_uris: ["http://example.com/cb"] });141 server.registerClient({ redirect_uris: ["not-a-url"] }),
136 expect(server.getClientCount()).toBe(before + 1);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);
137 });149 });
138 });150 });
139 151
@@ -148,8 +160,8 @@ describe("AnonymousOAuthServer", () => { · 8 unmodified lines
148 expect(redirectUrl.pathname).toBe("/callback");160 expect(redirectUrl.pathname).toBe("/callback");
149 });161 });
150 162
151 test("rejects unsupported response_type", () => {163 test("rejects unsupported response_type", async () => {
152 const client = server.registerClient({164 const client = await server.registerClient({
153 redirect_uris: ["http://localhost/cb"],165 redirect_uris: ["http://localhost/cb"],
154 });166 });
155 const params = new URLSearchParams({167 const params = new URLSearchParams({
@@ -159,10 +171,10 @@ describe("AnonymousOAuthServer", () => { · 3 unmodified lines
159 code_challenge: "abc",171 code_challenge: "abc",
160 code_challenge_method: "S256",172 code_challenge_method: "S256",
161 });173 });
162 expect(() => server.authorize(params)).toThrow("response_type must be 'code'");174 await expect(server.authorize(params)).rejects.toThrow("response_type must be 'code'");
163 });175 });
164 176
165 test("rejects unknown client_id", () => {177 test("rejects unknown client_id", async () => {
166 const params = new URLSearchParams({178 const params = new URLSearchParams({
167 response_type: "code",179 response_type: "code",
168 client_id: "unknown",180 client_id: "unknown",
@@ -170,11 +182,11 @@ describe("AnonymousOAuthServer", () => { · 1 unmodified line
170 code_challenge: "abc",182 code_challenge: "abc",
171 code_challenge_method: "S256",183 code_challenge_method: "S256",
172 });184 });
173 expect(() => server.authorize(params)).toThrow("Unknown client_id");185 await expect(server.authorize(params)).rejects.toThrow("Unknown client_id");
174 });186 });
175 187
176 test("rejects mismatched redirect_uri", () => {188 test("rejects mismatched redirect_uri", async () => {
177 const client = server.registerClient({189 const client = await server.registerClient({
178 redirect_uris: ["http://localhost/cb"],190 redirect_uris: ["http://localhost/cb"],
179 });191 });
180 const params = new URLSearchParams({192 const params = new URLSearchParams({
@@ -184,11 +196,11 @@ describe("AnonymousOAuthServer", () => { · 3 unmodified lines
184 code_challenge: "abc",196 code_challenge: "abc",
185 code_challenge_method: "S256",197 code_challenge_method: "S256",
186 });198 });
187 expect(() => server.authorize(params)).toThrow("redirect_uri does not match");199 await expect(server.authorize(params)).rejects.toThrow("redirect_uri does not match");
188 });200 });
189 201
190 test("rejects missing PKCE challenge", () => {202 test("rejects missing PKCE challenge", async () => {
191 const client = server.registerClient({203 const client = await server.registerClient({
192 redirect_uris: ["http://localhost/cb"],204 redirect_uris: ["http://localhost/cb"],
193 });205 });
194 const params = new URLSearchParams({206 const params = new URLSearchParams({
@@ -196,11 +208,11 @@ describe("AnonymousOAuthServer", () => { · 1 unmodified line
196 client_id: client.client_id,208 client_id: client.client_id,
197 redirect_uri: "http://localhost/cb",209 redirect_uri: "http://localhost/cb",
198 });210 });
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");
200 });212 });
201 213
202 test("rejects non-S256 PKCE method", () => {214 test("rejects non-S256 PKCE method", async () => {
203 const client = server.registerClient({215 const client = await server.registerClient({
204 redirect_uris: ["http://localhost/cb"],216 redirect_uris: ["http://localhost/cb"],
205 });217 });
206 const params = new URLSearchParams({218 const params = new URLSearchParams({
@@ -210,7 +222,7 @@ describe("AnonymousOAuthServer", () => { · 3 unmodified lines
210 code_challenge: "abc",222 code_challenge: "abc",
211 code_challenge_method: "plain",223 code_challenge_method: "plain",
212 });224 });
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");
214 });226 });
215 });227 });
216 228
@@ -218,12 +230,13 @@ describe("AnonymousOAuthServer", () => { · 1 unmodified line
218 230
219 describe("exchangeToken", () => {231 describe("exchangeToken", () => {
220 test("exchanges code for a valid JWT access token", async () => {232 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);
222 234
223 const result = await server.exchangeToken(235 const result = await server.exchangeToken(
224 new URLSearchParams({236 new URLSearchParams({
225 grant_type: "authorization_code",237 grant_type: "authorization_code",
226 code,238 code,
239 client_id: client.client_id,
227 redirect_uri: redirectUri,240 redirect_uri: redirectUri,
228 code_verifier: codeVerifier,241 code_verifier: codeVerifier,
229 }),242 }),
@@ -241,11 +254,12 @@ describe("AnonymousOAuthServer", () => { · 11 unmodified lines
241 });254 });
242 255
243 test("code is single-use", async () => {256 test("code is single-use", async () => {
244 const { code, codeVerifier, redirectUri } = await registerAndAuthorize(server);257 const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
245 258
246 const body = new URLSearchParams({259 const body = new URLSearchParams({
247 grant_type: "authorization_code",260 grant_type: "authorization_code",
248 code,261 code,
262 client_id: client.client_id,
249 redirect_uri: redirectUri,263 redirect_uri: redirectUri,
250 code_verifier: codeVerifier,264 code_verifier: codeVerifier,
251 });265 });
@@ -263,6 +277,22 @@ describe("AnonymousOAuthServer", () => { · 11 unmodified lines
263 ).rejects.toThrow("grant_type must be authorization_code");277 ).rejects.toThrow("grant_type must be authorization_code");
264 });278 });
265 279
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
266 test("rejects expired code", async () => {296 test("rejects expired code", async () => {
267 // Create server with very short code expiry297 // Create server with very short code expiry
268 const shortServer = new AnonymousOAuthServer({298 const shortServer = new AnonymousOAuthServer({
@@ -271,7 +301,7 @@ describe("AnonymousOAuthServer", () => { · 2 unmodified lines
271 });301 });
272 await shortServer.init();302 await shortServer.init();
273 303
274 const { code, codeVerifier, redirectUri } = await registerAndAuthorize(shortServer);304 const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(shortServer);
275 305
276 // Wait a tick for the code to expire306 // Wait a tick for the code to expire
277 await new Promise((resolve) => setTimeout(resolve, 10));307 await new Promise((resolve) => setTimeout(resolve, 10));
@@ -281,6 +311,7 @@ describe("AnonymousOAuthServer", () => { · 3 unmodified lines
281 new URLSearchParams({311 new URLSearchParams({
282 grant_type: "authorization_code",312 grant_type: "authorization_code",
283 code,313 code,
314 client_id: client.client_id,
284 redirect_uri: redirectUri,315 redirect_uri: redirectUri,
285 code_verifier: codeVerifier,316 code_verifier: codeVerifier,
286 }),317 }),
@@ -289,13 +320,14 @@ describe("AnonymousOAuthServer", () => { · 2 unmodified lines
289 });320 });
290 321
291 test("rejects wrong redirect_uri", async () => {322 test("rejects wrong redirect_uri", async () => {
292 const { code, codeVerifier } = await registerAndAuthorize(server);323 const { client, code, codeVerifier } = await registerAndAuthorize(server);
293 324
294 await expect(325 await expect(
295 server.exchangeToken(326 server.exchangeToken(
296 new URLSearchParams({327 new URLSearchParams({
297 grant_type: "authorization_code",328 grant_type: "authorization_code",
298 code,329 code,
330 client_id: client.client_id,
299 redirect_uri: "http://evil.com/cb",331 redirect_uri: "http://evil.com/cb",
300 code_verifier: codeVerifier,332 code_verifier: codeVerifier,
301 }),333 }),
@@ -304,13 +336,14 @@ describe("AnonymousOAuthServer", () => { · 2 unmodified lines
304 });336 });
305 337
306 test("rejects wrong code_verifier", async () => {338 test("rejects wrong code_verifier", async () => {
307 const { code, redirectUri } = await registerAndAuthorize(server);339 const { client, code, redirectUri } = await registerAndAuthorize(server);
308 340
309 await expect(341 await expect(
310 server.exchangeToken(342 server.exchangeToken(
311 new URLSearchParams({343 new URLSearchParams({
312 grant_type: "authorization_code",344 grant_type: "authorization_code",
313 code,345 code,
346 client_id: client.client_id,
314 redirect_uri: redirectUri,347 redirect_uri: redirectUri,
315 code_verifier: "wrong-verifier-value",348 code_verifier: "wrong-verifier-value",
316 }),349 }),
@@ -319,13 +352,14 @@ describe("AnonymousOAuthServer", () => { · 2 unmodified lines
319 });352 });
320 353
321 test("rejects missing code_verifier", async () => {354 test("rejects missing code_verifier", async () => {
322 const { code, redirectUri } = await registerAndAuthorize(server);355 const { client, code, redirectUri } = await registerAndAuthorize(server);
323 356
324 await expect(357 await expect(
325 server.exchangeToken(358 server.exchangeToken(
326 new URLSearchParams({359 new URLSearchParams({
327 grant_type: "authorization_code",360 grant_type: "authorization_code",
328 code,361 code,
362 client_id: client.client_id,
329 redirect_uri: redirectUri,363 redirect_uri: redirectUri,
330 }),364 }),
331 ),365 ),
@@ -337,11 +371,12 @@ describe("AnonymousOAuthServer", () => { · 5 unmodified lines
337 371
338 describe("verifyToken", () => {372 describe("verifyToken", () => {
339 test("verifies a self-issued token", async () => {373 test("verifies a self-issued token", async () => {
340 const { code, codeVerifier, redirectUri } = await registerAndAuthorize(server);374 const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
341 const { access_token } = await server.exchangeToken(375 const { access_token } = await server.exchangeToken(
342 new URLSearchParams({376 new URLSearchParams({
343 grant_type: "authorization_code",377 grant_type: "authorization_code",
344 code,378 code,
379 client_id: client.client_id,
345 redirect_uri: redirectUri,380 redirect_uri: redirectUri,
346 code_verifier: codeVerifier,381 code_verifier: codeVerifier,
347 }),382 }),
@@ -362,11 +397,12 @@ describe("AnonymousOAuthServer", () => { · 14 unmodified lines
362 const otherServer = createServer("http://other-server:4000");397 const otherServer = createServer("http://other-server:4000");
363 await otherServer.init();398 await otherServer.init();
364 399
365 const { code, codeVerifier, redirectUri } = await registerAndAuthorize(otherServer);400 const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(otherServer);
366 const { access_token } = await otherServer.exchangeToken(401 const { access_token } = await otherServer.exchangeToken(
367 new URLSearchParams({402 new URLSearchParams({
368 grant_type: "authorization_code",403 grant_type: "authorization_code",
369 code,404 code,
405 client_id: client.client_id,
370 redirect_uri: redirectUri,406 redirect_uri: redirectUri,
371 code_verifier: codeVerifier,407 code_verifier: codeVerifier,
372 }),408 }),
@@ -414,6 +450,111 @@ describe("AnonymousOAuthServer", () => { · 41 unmodified lines
414 });450 });
415 });451 });
416 452
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
417 // ── Each authorize creates a unique actor ───────────────────────────────558 // ── Each authorize creates a unique actor ───────────────────────────────
418 559
419 describe("unique anonymous identities", () => {560 describe("unique anonymous identities", () => {
@@ -421,11 +562,12 @@ describe("AnonymousOAuthServer", () => { · 1 unmodified line
421 const subs = new Set<string>();562 const subs = new Set<string>();
422 563
423 for (let i = 0; i < 5; i++) {564 for (let i = 0; i < 5; i++) {
424 const { code, codeVerifier, redirectUri } = await registerAndAuthorize(server);565 const { client, code, codeVerifier, redirectUri } = await registerAndAuthorize(server);
425 const { access_token } = await server.exchangeToken(566 const { access_token } = await server.exchangeToken(
426 new URLSearchParams({567 new URLSearchParams({
427 grant_type: "authorization_code",568 grant_type: "authorization_code",
428 code,569 code,
570 client_id: client.client_id,
429 redirect_uri: redirectUri,571 redirect_uri: redirectUri,
430 code_verifier: codeVerifier,572 code_verifier: codeVerifier,
431 }),573 }),
executor/lib/anonymous-oauth.ts+56−20
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
36 jwtVerify,36 jwtVerify,
37 type JWK,37 type JWK,
38 type CryptoKey as JoseCryptoKey,38 type CryptoKey as JoseCryptoKey,
39 type JWTVerifyGetKey,
39} from "jose";40} from "jose";
40 41
41// ---------------------------------------------------------------------------42// ---------------------------------------------------------------------------
@@ -66,6 +67,8 @@ export interface AnonOAuthConfig { · 24 unmodified lines
66 accessTokenTtlSeconds?: number;67 accessTokenTtlSeconds?: number;
67 /** Authorization code TTL in seconds. Default 120 s. */68 /** Authorization code TTL in seconds. Default 120 s. */
68 codeExpirySeconds?: number;69 codeExpirySeconds?: number;
70 /** Maximum number of in-flight authorization codes. Default 10 000. */
71 maxPendingCodes?: number;
69 /** Pluggable storage backend for keys & client registrations. */72 /** Pluggable storage backend for keys & client registrations. */
70 storage?: OAuthStorage;73 storage?: OAuthStorage;
71}74}
@@ -141,21 +144,27 @@ export class InMemoryOAuthStorage implements OAuthStorage { · 69 unmodified lines
141 144
142export class AnonymousOAuthServer {145export class AnonymousOAuthServer {
143 private readonly issuer: string;146 private readonly issuer: string;
147 private readonly audience: string;
144 private readonly accessTokenTtlSeconds: number;148 private readonly accessTokenTtlSeconds: number;
145 private readonly codeExpirySeconds: number;149 private readonly codeExpirySeconds: number;
150 private readonly maxPendingCodes: number;
146 private readonly storage: OAuthStorage;151 private readonly storage: OAuthStorage;
147 152
148 private privateKey!: JoseCryptoKey;153 private privateKey!: JoseCryptoKey;
149 private publicJwk!: JWK;154 private publicJwk!: JWK;
150 private keyId!: string;155 private keyId!: string;
156 /** Cached local JWKS for fast token verification (built once on init). */
157 private localJwks!: JWTVerifyGetKey;
151 158
152 /** In-memory authorization codes (short-lived, cleaned up on use). */159 /** In-memory authorization codes (short-lived, cleaned up on use). */
153 private readonly codes = new Map<string, AuthorizationCode>();160 private readonly codes = new Map<string, AuthorizationCode>();
154 161
155 constructor(config: AnonOAuthConfig) {162 constructor(config: AnonOAuthConfig) {
156 this.issuer = config.issuer.replace(/\/+$/, "");163 this.issuer = config.issuer.replace(/\/+$/, "");
164 this.audience = `${this.issuer}/mcp`;
157 this.accessTokenTtlSeconds = config.accessTokenTtlSeconds ?? 24 * 60 * 60;165 this.accessTokenTtlSeconds = config.accessTokenTtlSeconds ?? 24 * 60 * 60;
158 this.codeExpirySeconds = config.codeExpirySeconds ?? 120;166 this.codeExpirySeconds = config.codeExpirySeconds ?? 120;
167 this.maxPendingCodes = config.maxPendingCodes ?? 10_000;
159 this.storage = config.storage ?? new InMemoryOAuthStorage();168 this.storage = config.storage ?? new InMemoryOAuthStorage();
160 }169 }
161 170
@@ -178,32 +187,38 @@ export class AnonymousOAuthServer { · 16 unmodified lines
178 this.keyId = existing.keyId;187 this.keyId = existing.keyId;
179 this.publicJwk = { ...existing.publicKeyJwk, kid: this.keyId, use: "sig", alg: existing.algorithm };188 this.publicJwk = { ...existing.publicKeyJwk, kid: this.keyId, use: "sig", alg: existing.algorithm };
180 this.privateKey = await importJWK(existing.privateKeyJwk, existing.algorithm) as JoseCryptoKey;189 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 });
182 }207 }
183 208
184 // No existing key — generate a fresh pair and persist it209 // Build cached local JWKS for fast token verification
185 const { privateKey, publicKey } = await generateKeyPair("RS256");210 this.localJwks = createLocalJWKSet({ keys: [this.publicJwk] });
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 });
200 }211 }
201 212
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 */
203 async initWithKeys(privateKey: JoseCryptoKey, publicJwk: JWK): Promise<void> {217 async initWithKeys(privateKey: JoseCryptoKey, publicJwk: JWK): Promise<void> {
204 this.privateKey = privateKey;218 this.privateKey = privateKey;
205 this.keyId = publicJwk.kid ?? `anon_key_${crypto.randomUUID().slice(0, 8)}`;219 this.keyId = publicJwk.kid ?? `anon_key_${crypto.randomUUID().slice(0, 8)}`;
206 this.publicJwk = { ...publicJwk, kid: this.keyId, use: "sig", alg: "RS256" };220 this.publicJwk = { ...publicJwk, kid: this.keyId, use: "sig", alg: "RS256" };
221 this.localJwks = createLocalJWKSet({ keys: [this.publicJwk] });
207 }222 }
208 223
209 // -------------------------------------------------------------------------224 // -------------------------------------------------------------------------
@@ -250,6 +265,11 @@ export class AnonymousOAuthServer { · 40 unmodified lines
250 if (typeof uri !== "string" || uri.length === 0) {265 if (typeof uri !== "string" || uri.length === 0) {
251 throw new OAuthBadRequest("Each redirect_uri must be a non-empty string");266 throw new OAuthBadRequest("Each redirect_uri must be a non-empty string");
252 }267 }
268 try {
269 new URL(uri);
270 } catch {
271 throw new OAuthBadRequest(`Invalid redirect_uri: ${uri}`);
272 }
253 }273 }
254 274
255 const clientId = `anon_client_${crypto.randomUUID()}`;275 const clientId = `anon_client_${crypto.randomUUID()}`;
@@ -305,6 +325,15 @@ export class AnonymousOAuthServer { · 49 unmodified lines
305 // Generate anonymous identity325 // Generate anonymous identity
306 const actorId = `anon_${crypto.randomUUID()}`;326 const actorId = `anon_${crypto.randomUUID()}`;
307 327
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
308 // Issue authorization code (always in-memory — short-lived, single-use)337 // Issue authorization code (always in-memory — short-lived, single-use)
309 const code = crypto.randomUUID();338 const code = crypto.randomUUID();
310 this.codes.set(code, {339 this.codes.set(code, {
@@ -363,6 +392,12 @@ export class AnonymousOAuthServer { · 52 unmodified lines
363 throw new OAuthBadRequest("authorization code has expired");392 throw new OAuthBadRequest("authorization code has expired");
364 }393 }
365 394
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
366 // Verify redirect_uri matches401 // Verify redirect_uri matches
367 const redirectUri = body.get("redirect_uri");402 const redirectUri = body.get("redirect_uri");
368 if (redirectUri !== storedCode.redirectUri) {403 if (redirectUri !== storedCode.redirectUri) {
@@ -387,6 +422,7 @@ export class AnonymousOAuthServer { · 18 unmodified lines
387 })422 })
388 .setProtectedHeader({ alg: "RS256", kid: this.keyId })423 .setProtectedHeader({ alg: "RS256", kid: this.keyId })
389 .setIssuer(this.issuer)424 .setIssuer(this.issuer)
425 .setAudience(this.audience)
390 .setIssuedAt()426 .setIssuedAt()
391 .setExpirationTime(`${this.accessTokenTtlSeconds}s`)427 .setExpirationTime(`${this.accessTokenTtlSeconds}s`)
392 .setJti(crypto.randomUUID())428 .setJti(crypto.randomUUID())
@@ -407,9 +443,9 @@ export class AnonymousOAuthServer { · 14 unmodified lines
407 token: string,443 token: string,
408 ): Promise<{ sub: string; provider: string } | null> {444 ): Promise<{ sub: string; provider: string } | null> {
409 try {445 try {
410 const jwks = createLocalJWKSet({ keys: [this.publicJwk] });446 const { payload } = await jwtVerify(token, this.localJwks, {
411 const { payload } = await jwtVerify(token, jwks, {
412 issuer: this.issuer,447 issuer: this.issuer,
448 audience: this.audience,
413 });449 });
414 450
415 if (typeof payload.sub !== "string" || payload.sub.length === 0) {451 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
13import { ConvexHttpClient } from "convex/browser";
14import { api } from "../convex/_generated/api";
15import type {
16 OAuthStorage,
17 StoredSigningKey,
18 AnonOAuthClientRegistration,
19} from "./anonymous-oauth";
20
21export 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
3import path from "node:path";3import path from "node:path";
4 4
5const CONVEX_BACKEND_REPO = "get-convex/convex-backend";5const CONVEX_BACKEND_REPO = "get-convex/convex-backend";
6const EXECUTOR_RELEASE_REPO = Bun.env.EXECUTOR_REPO ?? "RhysSullivan/assistant";
7const NODE_VERSION = "22.22.0";
8const CONVEX_CLI_VERSION = "1.31.7";
9const CONVEX_CLIENT_HEADER = `npm-cli-${CONVEX_CLI_VERSION}`;
10
11type HostPlatform = "linux" | "darwin";
12type HostArch = "x64" | "arm64";
6 13
7export interface ManagedRuntimeConfig {14export interface ManagedRuntimeConfig {
8 instanceName: string;15 instanceName: string;
@@ -22,6 +29,25 @@ export interface ManagedRuntimeInfo { · 13 unmodified lines
22 storageDir: string;29 storageDir: string;
23 configPath: string;30 configPath: string;
24 config: ManagedRuntimeConfig;31 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
43function 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 };
25}51}
26 52
27function runtimeRootDir(): string {53function runtimeRootDir(): string {
@@ -37,22 +63,36 @@ function backendBinaryName(): string { · 9 unmodified lines
37}63}
38 64
39function backendAssetName(): string {65function backendAssetName(): string {
40 if (process.platform === "linux" && process.arch === "x64") {66 const target = hostTarget();
67 if (target.platform === "linux" && target.arch === "x64") {
41 return "convex-local-backend-x86_64-unknown-linux-gnu.zip";68 return "convex-local-backend-x86_64-unknown-linux-gnu.zip";
42 }69 }
43 if (process.platform === "linux" && process.arch === "arm64") {70 if (target.platform === "linux" && target.arch === "arm64") {
44 return "convex-local-backend-aarch64-unknown-linux-gnu.zip";71 return "convex-local-backend-aarch64-unknown-linux-gnu.zip";
45 }72 }
46 if (process.platform === "darwin" && process.arch === "x64") {73 if (target.platform === "darwin" && target.arch === "x64") {
47 return "convex-local-backend-x86_64-apple-darwin.zip";74 return "convex-local-backend-x86_64-apple-darwin.zip";
48 }75 }
49 if (process.platform === "darwin" && process.arch === "arm64") {76 return "convex-local-backend-aarch64-apple-darwin.zip";
50 return "convex-local-backend-aarch64-apple-darwin.zip";77}
51 }
52 78
53 throw new Error(79function webAssetName(): string {
54 `Unsupported platform/arch for managed Convex backend: ${process.platform}/${process.arch}`,80 const target = hostTarget();
55 );81 return `executor-web-${target.platform}-${target.arch}.tar.gz`;
82}
83
84function nodeArchiveName(): string {
85 const target = hostTarget();
86 return `node-v${NODE_VERSION}-${target.platform}-${target.arch}.tar.gz`;
87}
88
89function nodeDirectoryName(): string {
90 const target = hostTarget();
91 return `node-v${NODE_VERSION}-${target.platform}-${target.arch}`;
92}
93
94function npmBinaryName(): string {
95 return process.platform === "win32" ? "npm.cmd" : "npm";
56}96}
57 97
58function randomHex(bytes: number): string {98function randomHex(bytes: number): string {
@@ -65,7 +105,7 @@ function defaultConfig(): ManagedRuntimeConfig { · 6 unmodified lines
65 const backendPort = Number(Bun.env.EXECUTOR_BACKEND_PORT ?? 5410);105 const backendPort = Number(Bun.env.EXECUTOR_BACKEND_PORT ?? 5410);
66 const siteProxyPort = Number(Bun.env.EXECUTOR_BACKEND_SITE_PORT ?? 5411);106 const siteProxyPort = Number(Bun.env.EXECUTOR_BACKEND_SITE_PORT ?? 5411);
67 return {107 return {
68 instanceName: Bun.env.EXECUTOR_INSTANCE_NAME ?? "executor-local",108 instanceName: Bun.env.EXECUTOR_INSTANCE_NAME ?? "anonymous-executor",
69 instanceSecret: Bun.env.EXECUTOR_INSTANCE_SECRET ?? randomHex(32),109 instanceSecret: Bun.env.EXECUTOR_INSTANCE_SECRET ?? randomHex(32),
70 hostInterface: Bun.env.EXECUTOR_BACKEND_INTERFACE ?? "127.0.0.1",110 hostInterface: Bun.env.EXECUTOR_BACKEND_INTERFACE ?? "127.0.0.1",
71 backendPort,111 backendPort,
@@ -111,12 +151,11 @@ async function runProcess( · 39 unmodified lines
111async function downloadWithFetch(url: string, destinationPath: string): Promise<void> {151async function downloadWithFetch(url: string, destinationPath: string): Promise<void> {
112 const response = await fetch(url);152 const response = await fetch(url);
113 if (!response.ok || !response.body) {153 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})`);
115 }155 }
116 156
117 const totalBytes = Number(response.headers.get("content-length") ?? "0");157 const totalBytes = Number(response.headers.get("content-length") ?? "0");
118 const totalMb = totalBytes > 0 ? (totalBytes / (1024 * 1024)).toFixed(1) : null;158 const totalMb = totalBytes > 0 ? (totalBytes / (1024 * 1024)).toFixed(1) : null;
119
120 const file = await fs.open(destinationPath, "w");159 const file = await fs.open(destinationPath, "w");
121 let downloadedBytes = 0;160 let downloadedBytes = 0;
122 let nextPercentLog = 10;161 let nextPercentLog = 10;
@@ -135,7 +174,9 @@ async function downloadWithFetch(url: string, destinationPath: string): Promise< · 12 unmodified lines
135 if (totalBytes > 0) {174 if (totalBytes > 0) {
136 const percent = Math.floor((downloadedBytes / totalBytes) * 100);175 const percent = Math.floor((downloadedBytes / totalBytes) * 100);
137 if (percent >= nextPercentLog) {176 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 );
139 nextPercentLog += 10;180 nextPercentLog += 10;
140 }181 }
141 } else {182 } else {
@@ -187,7 +228,16 @@ async function extractZipArchive(archivePath: string, destinationDir: string): P · 45 unmodified lines
187 stdin: "ignore",228 stdin: "ignore",
188 });229 });
189 if (python.exitCode !== 0) {230 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
235async 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}`);
191 }241 }
192}242}
193 243
@@ -199,6 +249,13 @@ function runtimeInfo(): ManagedRuntimeInfo { · 5 unmodified lines
199 const dbPath = path.join(rootDir, "convex-data", "convex_local_backend.sqlite3");249 const dbPath = path.join(rootDir, "convex-data", "convex_local_backend.sqlite3");
200 const storageDir = path.join(rootDir, "convex-data", "storage");250 const storageDir = path.join(rootDir, "convex-data", "storage");
201 const configPath = path.join(rootDir, "convex-backend.json");251 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();
202 259
203 return {260 return {
204 rootDir,261 rootDir,
@@ -210,6 +267,15 @@ function runtimeInfo(): ManagedRuntimeInfo { · 5 unmodified lines
210 storageDir,267 storageDir,
211 configPath,268 configPath,
212 config: defaultConfig(),269 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}`,
213 };279 };
214}280}
215 281
@@ -217,6 +283,11 @@ async function ensureConfig(info: ManagedRuntimeInfo): Promise<ManagedRuntimeCon · 1 unmodified line
217 if (await pathExists(info.configPath)) {283 if (await pathExists(info.configPath)) {
218 const raw = await fs.readFile(info.configPath, "utf8");284 const raw = await fs.readFile(info.configPath, "utf8");
219 const parsed = JSON.parse(raw) as ManagedRuntimeConfig;285 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 }
220 return parsed;291 return parsed;
221 }292 }
222 293
@@ -254,6 +325,259 @@ async function ensureBackendBinary(info: ManagedRuntimeInfo): Promise<void> { · 31 unmodified lines
254 }325 }
255}326}
256 327
328async 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
354async 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
388async 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
414async 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
433async 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
444async 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
471async 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
490async 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
535async 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
257function backendArgs(info: ManagedRuntimeInfo, additionalArgs: string[]): string[] {581function backendArgs(info: ManagedRuntimeInfo, additionalArgs: string[]): string[] {
258 const cfg = info.config;582 const cfg = info.config;
259 return [583 return [
@@ -286,7 +610,60 @@ export async function ensureManagedRuntime(): Promise<ManagedRuntimeInfo> { · 26 unmodified lines
286 610
287export async function runManagedBackend(args: string[]): Promise<number> {611export async function runManagedBackend(args: string[]): Promise<number> {
288 const info = await ensureManagedRuntime();612 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
638export 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 });
290 return proc.exitCode;667 return proc.exitCode;
291}668}
292 669
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
2import { Client } from "@modelcontextprotocol/sdk/client/index.js";2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";3import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";4import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
5import type { OAuthClientProvider, OAuthClientMetadata } from "@modelcontextprotocol/sdk/client/auth.js";5import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
6import type { OAuthClientMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
6import { AnonymousOAuthServer, OAuthBadRequest } from "./anonymous-oauth";7import { AnonymousOAuthServer, OAuthBadRequest } from "./anonymous-oauth";
7import { handleMcpRequest, type McpWorkspaceContext } from "./mcp_server";8import { handleMcpRequest, type McpWorkspaceContext } from "./mcp_server";
8import type { AnonymousContext, CreateTaskInput, TaskRecord, ToolDescriptor } from "./types";9import type { AnonymousContext, CreateTaskInput, TaskRecord, ToolDescriptor } from "./types";
@@ -130,7 +131,7 @@ class TestOAuthClientProvider implements OAuthClientProvider { · 121 unmodified lines
130 131
131 get clientMetadata(): OAuthClientMetadata {132 get clientMetadata(): OAuthClientMetadata {
132 return {133 return {
133 redirect_uris: [new URL("http://localhost:0/callback")],134 redirect_uris: ["http://localhost:0/callback"],
134 client_name: "mcp-oauth-sdk-test",135 client_name: "mcp-oauth-sdk-test",
135 };136 };
136 }137 }
@@ -216,7 +217,8 @@ beforeAll(async () => { · 79 unmodified lines
216 if (url.pathname === "/register" && request.method === "POST") {217 if (url.pathname === "/register" && request.method === "POST") {
217 const body = await request.json();218 const body = await request.json();
218 try {219 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 });
220 } catch (e) {222 } catch (e) {
221 if (e instanceof OAuthBadRequest) {223 if (e instanceof OAuthBadRequest) {
222 return Response.json({ error: "invalid_client_metadata", error_description: e.message }, { status: 400 });224 return Response.json({ error: "invalid_client_metadata", error_description: e.message }, { status: 400 });
@@ -227,7 +229,7 @@ beforeAll(async () => { · 4 unmodified lines
227 229
228 if (url.pathname === "/authorize" && request.method === "GET") {230 if (url.pathname === "/authorize" && request.method === "GET") {
229 try {231 try {
230 const { redirectTo } = oauthServer.authorize(url.searchParams);232 const { redirectTo } = await oauthServer.authorize(url.searchParams);
231 return Response.redirect(redirectTo, 302);233 return Response.redirect(redirectTo, 302);
232 } catch (e) {234 } catch (e) {
233 if (e instanceof OAuthBadRequest) {235 if (e instanceof OAuthBadRequest) {
@@ -350,15 +352,24 @@ describe("MCP SDK Client OAuth E2E", () => { · 116 unmodified lines
350 // Step 4: Complete the auth flow — SDK exchanges code for token352 // Step 4: Complete the auth flow — SDK exchanges code for token
351 await transport.finishAuth(code);353 await transport.finishAuth(code);
352 354
353 // Step 5: Now connect succeeds355 // Step 5: Create a new transport + client (the old transport was already started
354 await client.connect(transport);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);
355 366
356 // Step 6: Make a tool call — this uses the Bearer token automatically367 // Step 6: Make a tool call — this uses the Bearer token automatically
357 const tools = await client.listTools();368 const tools = await client2.listTools();
358 expect(tools.tools.length).toBeGreaterThan(0);369 expect(tools.tools.length).toBeGreaterThan(0);
359 expect(tools.tools[0].name).toBe("run_code");370 expect(tools.tools[0].name).toBe("run_code");
360 371
361 const result = (await client.callTool({372 const result = (await client2.callTool({
362 name: "run_code",373 name: "run_code",
363 arguments: {374 arguments: {
364 code: "return 42",375 code: "return 42",
@@ -379,7 +390,7 @@ describe("MCP SDK Client OAuth E2E", () => { · 14 unmodified lines
379 expect(typeof structured?.actorId).toBe("string");390 expect(typeof structured?.actorId).toBe("string");
380 391
381 // Cleanup392 // Cleanup
382 await transport.close().catch(() => {});393 await transport2.close().catch(() => {});
383 await client.close().catch(() => {});394 await client2.close().catch(() => {});
384 });395 });
385});396});
executor/lib/mcp-gateway-oauth.test.ts+4−2
@@ -85,7 +85,7 @@ beforeAll(async () => { · 84 unmodified lines
85 if (url.pathname === "/register" && request.method === "POST") {85 if (url.pathname === "/register" && request.method === "POST") {
86 const body = await request.json();86 const body = await request.json();
87 try {87 try {
88 const reg = oauthServer.registerClient(body as any);88 const reg = await oauthServer.registerClient(body as any);
89 return Response.json(reg, { status: 201 });89 return Response.json(reg, { status: 201 });
90 } catch (error) {90 } catch (error) {
91 if (error instanceof OAuthBadRequest) {91 if (error instanceof OAuthBadRequest) {
@@ -100,7 +100,7 @@ beforeAll(async () => { · 8 unmodified lines
100 100
101 if (url.pathname === "/authorize" && request.method === "GET") {101 if (url.pathname === "/authorize" && request.method === "GET") {
102 try {102 try {
103 const { redirectTo } = oauthServer.authorize(url.searchParams);103 const { redirectTo } = await oauthServer.authorize(url.searchParams);
104 return Response.redirect(redirectTo, 302);104 return Response.redirect(redirectTo, 302);
105 } catch (error) {105 } catch (error) {
106 if (error instanceof OAuthBadRequest) {106 if (error instanceof OAuthBadRequest) {
@@ -293,6 +293,7 @@ describe("MCP Gateway OAuth Integration", () => { · 186 unmodified lines
293 body: new URLSearchParams({293 body: new URLSearchParams({
294 grant_type: "authorization_code",294 grant_type: "authorization_code",
295 code,295 code,
296 client_id,
296 redirect_uri: "http://localhost:9999/callback",297 redirect_uri: "http://localhost:9999/callback",
297 code_verifier: codeVerifier,298 code_verifier: codeVerifier,
298 }).toString(),299 }).toString(),
@@ -413,6 +414,7 @@ describe("MCP Gateway OAuth Integration", () => { · 114 unmodified lines
413 body: new URLSearchParams({414 body: new URLSearchParams({
414 grant_type: "authorization_code",415 grant_type: "authorization_code",
415 code,416 code,
417 client_id,
416 redirect_uri: "http://localhost:9999/callback",418 redirect_uri: "http://localhost:9999/callback",
417 code_verifier: codeVerifier,419 code_verifier: codeVerifier,
418 }).toString(),420 }).toString(),
executor/mcp-gateway.ts+11−5
@@ -3,6 +3,7 @@ import { createRemoteJWKSet, jwtVerify } from "jose"; · 2 unmodified lines
3import { api } from "./convex/_generated/api";3import { api } from "./convex/_generated/api";
4import { handleMcpRequest, type McpWorkspaceContext } from "./lib/mcp_server";4import { handleMcpRequest, type McpWorkspaceContext } from "./lib/mcp_server";
5import { AnonymousOAuthServer, OAuthBadRequest } from "./lib/anonymous-oauth";5import { AnonymousOAuthServer, OAuthBadRequest } from "./lib/anonymous-oauth";
6import { ConvexOAuthStorage } from "./lib/convex-oauth-storage";
6import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescriptor } from "./lib/types";7import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescriptor } from "./lib/types";
7import type { Id } from "./convex/_generated/dataModel";8import type { Id } from "./convex/_generated/dataModel";
8 9
@@ -44,7 +45,12 @@ function loadConfigFromEnv(): void { · 35 unmodified lines
44}45}
45 46
46async function initAnonymousOAuth(issuer: string): Promise<void> {47async 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 });
48 await anonOAuth.init();54 await anonOAuth.init();
49}55}
50 56
@@ -381,7 +387,7 @@ async function handleRegister(request: Request): Promise<Response> { · 330 unmodified lines
381 }387 }
382 388
383 try {389 try {
384 const registration = anonOAuth.registerClient(body as any);390 const registration = await anonOAuth.registerClient(body as any);
385 return Response.json(registration, { status: 201 });391 return Response.json(registration, { status: 201 });
386 } catch (error) {392 } catch (error) {
387 if (error instanceof OAuthBadRequest) {393 if (error instanceof OAuthBadRequest) {
@@ -394,14 +400,14 @@ async function handleRegister(request: Request): Promise<Response> { · 6 unmodified lines
394 }400 }
395}401}
396 402
397function handleAuthorize(request: Request): Response {403async function handleAuthorize(request: Request): Promise<Response> {
398 if (!anonOAuth) {404 if (!anonOAuth) {
399 return Response.json({ error: "Anonymous OAuth server not initialized" }, { status: 500 });405 return Response.json({ error: "Anonymous OAuth server not initialized" }, { status: 500 });
400 }406 }
401 407
402 const url = new URL(request.url);408 const url = new URL(request.url);
403 try {409 try {
404 const { redirectTo } = anonOAuth.authorize(url.searchParams);410 const { redirectTo } = await anonOAuth.authorize(url.searchParams);
405 return Response.redirect(redirectTo, 302);411 return Response.redirect(redirectTo, 302);
406 } catch (error) {412 } catch (error) {
407 if (error instanceof OAuthBadRequest) {413 if (error instanceof OAuthBadRequest) {
@@ -501,7 +507,7 @@ export async function startMcpGateway( · 93 unmodified lines
501 }507 }
502 508
503 if (url.pathname === "/authorize" && request.method === "GET") {509 if (url.pathname === "/authorize" && request.method === "GET") {
504 return handleAuthorize(request);510 return await handleAuthorize(request);
505 }511 }
506 512
507 if (url.pathname === "/token" && request.method === "POST") {513 if (url.pathname === "/token" && request.method === "POST") {
executor/package.json+1−0
@@ -7,6 +7,7 @@ · 6 unmodified lines
7 "up": "bun run executor.ts up",7 "up": "bun run executor.ts up",
8 "backend": "bun run executor.ts backend",8 "backend": "bun run executor.ts backend",
9 "convex": "bun run executor.ts backend",9 "convex": "bun run executor.ts backend",
10 "web": "bun run executor.ts web",
10 "gateway": "bun run executor.ts gateway",11 "gateway": "bun run executor.ts gateway",
11 "deploy": "bunx convex deploy",12 "deploy": "bunx convex deploy",
12 "codegen": "bunx convex codegen",13 "codegen": "bunx convex codegen",
executor/scripts/build-release.ts+77−0
@@ -17,6 +17,16 @@ const targets: ReleaseTarget[] = [ · 16 unmodified lines
17 { platform: "darwin", arch: "arm64", bunTarget: "bun-darwin-arm64" },17 { platform: "darwin", arch: "arm64", bunTarget: "bun-darwin-arm64" },
18];18];
19 19
20function 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
20function archiveName(platform: ReleaseTarget["platform"], arch: ReleaseTarget["arch"]): string {30function archiveName(platform: ReleaseTarget["platform"], arch: ReleaseTarget["arch"]): string {
21 return `executor-${platform}-${arch}.tar.gz`;31 return `executor-${platform}-${arch}.tar.gz`;
22}32}
@@ -39,6 +49,71 @@ async function runArchiveCommand(command: string[], cwd?: string): Promise<void> · 16 unmodified lines
39 }49 }
40}50}
41 51
52async function pathExists(filePath: string): Promise<boolean> {
53 try {
54 await fs.access(filePath);
55 return true;
56 } catch {
57 return false;
58 }
59}
60
61async 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
75async 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
42async function main(): Promise<void> {117async function main(): Promise<void> {
43 const rootDir = path.resolve(import.meta.dir, "..");118 const rootDir = path.resolve(import.meta.dir, "..");
44 const releaseDir = path.join(rootDir, "dist", "release");119 const releaseDir = path.join(rootDir, "dist", "release");
@@ -78,6 +153,8 @@ async function main(): Promise<void> { · 33 unmodified lines
78 console.log(`built ${path.basename(archivePath)}`);153 console.log(`built ${path.basename(archivePath)}`);
79 }154 }
80 155
156 await buildWebArtifact(rootDir, releaseDir, checksums);
157
81 await Bun.write(path.join(releaseDir, "checksums.txt"), `${checksums.join("\n")}\n`);158 await Bun.write(path.join(releaseDir, "checksums.txt"), `${checksums.join("\n")}\n`);
82 console.log(`wrote ${path.join("dist", "release", "checksums.txt")}`);159 console.log(`wrote ${path.join("dist", "release", "checksums.txt")}`);
83}160}
executor/uninstall+68−0
@@ -0,0 +1,68 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}"
5RUNTIME_DIR="${EXECUTOR_RUNTIME_DIR:-$HOME/.executor/runtime}"
6LEGACY_WEB_DIR="${EXECUTOR_LEGACY_WEB_DIR:-$HOME/.executor/web}"
7EXECUTOR_HOME="${EXECUTOR_HOME_DIR:-$HOME/.executor}"
8ASSUME_YES="0"
9
10usage() {
11 cat <<EOF
12Executor uninstaller
13
14Usage:
15 bash executor/uninstall [--yes]
16
17Options:
18 -y, --yes Skip confirmation prompt
19 -h, --help Show this help
20EOF
21}
22
23while [[ $# -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
38done
39
40if [[ "$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
51fi
52
53rm -f "$INSTALL_DIR/executor"
54rm -rf "$RUNTIME_DIR"
55rm -rf "$LEGACY_WEB_DIR"
56
57if [[ -d "$INSTALL_DIR" ]]; then
58 rmdir "$INSTALL_DIR" 2>/dev/null || true
59fi
60
61if [[ -d "$EXECUTOR_HOME" ]]; then
62 rmdir "$EXECUTOR_HOME" 2>/dev/null || true
63fi
64
65echo "Executor uninstall complete."
66echo ""
67echo "If you previously added PATH manually, remove this line from your shell rc:"
68echo " export PATH=$INSTALL_DIR:\$PATH"