History

deploy install url

Rhys Sullivan committed 9 months ago

Commit c28dabb1 parent f0c7f537 files · +370 −37Browse files

Unified · Split

7 files
  1. executor/apps/web/next.config.ts +11 −0
  2. executor/apps/web/src/app/install/route.ts +7 −0
  3. executor/convex/_generated/api.d.ts +29 −0
  4. executor/convex/database.ts +112 −0
  5. executor/convex/schema.ts +33 −0
  6. executor/install +61 −18
  7. executor/lib/anonymous-oauth.ts +117 −19
executor/apps/web/next.config.ts+11−0
@@ -8,6 +8,8 @@ const appShellRewriteExclusions = [ · 7 unmodified lines
88 "sign-up(?:/|$)",
99 "sign-out(?:/|$)",
1010 "callback(?:/|$)",
11+ "install(?:/|$)",
12+ "install\\.sh(?:/|$)",
1113 "static-app-shell(?:/|$)",
1214 ".*\\..*",
1315 ].join("|");
@@ -29,6 +31,15 @@ const nextConfig: NextConfig = { · 15 unmodified lines
2931 },
3032 ];
3133 },
34+ async redirects() {
35+ return [
36+ {
37+ source: "/install.sh",
38+ destination: "/install",
39+ permanent: false,
40+ },
41+ ];
42+ },
3243 };
3344
3445 export default nextConfig;
executor/apps/web/src/app/install/route.ts+7−0
@@ -0,0 +1,7 @@
1+const DEFAULT_INSTALL_SCRIPT_URL =
2+ "https://raw.githubusercontent.com/RhysSullivan/executor/main/executor/install";
3+
4+export async function GET(): Promise<Response> {
5+ const target = process.env.EXECUTOR_INSTALL_SCRIPT_URL ?? DEFAULT_INSTALL_SCRIPT_URL;
6+ return Response.redirect(target, 302);
7+}
executor/convex/_generated/api.d.ts+29−0
@@ -548,6 +548,18 @@ export declare const internal: { · 547 unmodified lines
548548 { sourceId: string; workspaceId: Id<"workspaces"> },
549549 any
550550 >;
551+ getActiveAnonymousOauthSigningKey: FunctionReference<
552+ "query",
553+ "internal",
554+ {},
555+ any
556+ >;
557+ getAnonymousOauthClient: FunctionReference<
558+ "query",
559+ "internal",
560+ { clientId: string },
561+ any
562+ >;
551563 getApproval: FunctionReference<
552564 "query",
553565 "internal",
@@ -639,6 +651,12 @@ export declare const internal: { · 85 unmodified lines
639651 { taskId: string },
640652 any
641653 >;
654+ registerAnonymousOauthClient: FunctionReference<
655+ "mutation",
656+ "internal",
657+ { clientId: string; clientName?: string; redirectUris: Array<string> },
658+ any
659+ >;
642660 resolveApproval: FunctionReference<
643661 "mutation",
644662 "internal",
@@ -661,6 +679,17 @@ export declare const internal: { · 16 unmodified lines
661679 },
662680 any
663681 >;
682+ storeAnonymousOauthSigningKey: FunctionReference<
683+ "mutation",
684+ "internal",
685+ {
686+ algorithm: string;
687+ keyId: string;
688+ privateKeyJwk: any;
689+ publicKeyJwk: any;
690+ },
691+ any
692+ >;
664693 upsertAccessPolicy: FunctionReference<
665694 "mutation",
666695 "internal",
executor/convex/database.ts+112−0
@@ -1133,3 +1133,115 @@ export const listTaskEvents = internalQuery({ · 1132 unmodified lines
11331133 return docs.map(mapTaskEvent);
11341134 },
11351135 });
1136+
1137+// ── Anonymous OAuth Signing Keys ─────────────────────────────────────────────
1138+
1139+/**
1140+ * Get the active anonymous OAuth signing key pair.
1141+ * Returns null if no key has been generated yet.
1142+ */
1143+export const getActiveAnonymousOauthSigningKey = internalQuery({
1144+ args: {},
1145+ handler: async (ctx) => {
1146+ const doc = await ctx.db
1147+ .query("anonymousOauthSigningKeys")
1148+ .withIndex("by_status", (q) => q.eq("status", "active"))
1149+ .first();
1150+
1151+ if (!doc) return null;
1152+
1153+ return {
1154+ keyId: doc.keyId,
1155+ algorithm: doc.algorithm,
1156+ privateKeyJwk: doc.privateKeyJwk,
1157+ publicKeyJwk: doc.publicKeyJwk,
1158+ createdAt: doc.createdAt,
1159+ };
1160+ },
1161+});
1162+
1163+/**
1164+ * Store a new anonymous OAuth signing key pair, rotating any previously active key.
1165+ */
1166+export const storeAnonymousOauthSigningKey = internalMutation({
1167+ args: {
1168+ keyId: v.string(),
1169+ algorithm: v.string(),
1170+ privateKeyJwk: v.any(),
1171+ publicKeyJwk: v.any(),
1172+ },
1173+ handler: async (ctx, args) => {
1174+ const now = Date.now();
1175+
1176+ // Rotate any currently active keys
1177+ const activeKeys = await ctx.db
1178+ .query("anonymousOauthSigningKeys")
1179+ .withIndex("by_status", (q) => q.eq("status", "active"))
1180+ .collect();
1181+
1182+ for (const key of activeKeys) {
1183+ await ctx.db.patch(key._id, { status: "rotated", rotatedAt: now });
1184+ }
1185+
1186+ // Insert the new active key
1187+ await ctx.db.insert("anonymousOauthSigningKeys", {
1188+ keyId: args.keyId,
1189+ algorithm: args.algorithm,
1190+ privateKeyJwk: args.privateKeyJwk,
1191+ publicKeyJwk: args.publicKeyJwk,
1192+ status: "active",
1193+ createdAt: now,
1194+ });
1195+ },
1196+});
1197+
1198+// ── Anonymous OAuth Client Registrations ────────────────────────────────────
1199+
1200+/**
1201+ * Register a new anonymous OAuth client (RFC 7591 dynamic client registration).
1202+ */
1203+export const registerAnonymousOauthClient = internalMutation({
1204+ args: {
1205+ clientId: v.string(),
1206+ clientName: v.optional(v.string()),
1207+ redirectUris: v.array(v.string()),
1208+ },
1209+ handler: async (ctx, args) => {
1210+ const now = Date.now();
1211+ await ctx.db.insert("anonymousOauthClients", {
1212+ clientId: args.clientId,
1213+ clientName: args.clientName,
1214+ redirectUris: args.redirectUris,
1215+ createdAt: now,
1216+ });
1217+
1218+ return {
1219+ client_id: args.clientId,
1220+ client_name: args.clientName,
1221+ redirect_uris: args.redirectUris,
1222+ created_at: now,
1223+ };
1224+ },
1225+});
1226+
1227+/**
1228+ * Look up an anonymous OAuth client by client_id.
1229+ */
1230+export const getAnonymousOauthClient = internalQuery({
1231+ args: { clientId: v.string() },
1232+ handler: async (ctx, args) => {
1233+ const doc = await ctx.db
1234+ .query("anonymousOauthClients")
1235+ .withIndex("by_client_id", (q) => q.eq("clientId", args.clientId))
1236+ .unique();
1237+
1238+ if (!doc) return null;
1239+
1240+ return {
1241+ client_id: doc.clientId,
1242+ client_name: doc.clientName,
1243+ redirect_uris: doc.redirectUris,
1244+ created_at: doc.createdAt,
1245+ };
1246+ },
1247+});
executor/convex/schema.ts+33−0
@@ -313,4 +313,37 @@ export default defineSchema({ · 312 unmodified lines
313313 .index("by_session_id", ["sessionId"])
314314 .index("by_workspace_actor", ["workspaceId", "actorId"])
315315 .index("by_account", ["accountId"]),
316+
317+ // ── Anonymous OAuth: Self-issued auth server for anonymous MCP clients ────
318+
319+ /**
320+ * Signing key pair for the self-issued anonymous OAuth server.
321+ * Persisted so tokens survive gateway restarts.
322+ * Typically only one "active" row at a time.
323+ */
324+ anonymousOauthSigningKeys: defineTable({
325+ keyId: v.string(), // kid claim: anon_key_<short-uuid>
326+ algorithm: v.string(), // e.g. "RS256"
327+ /** JWK-encoded private key (JSON object). */
328+ privateKeyJwk: v.any(),
329+ /** JWK-encoded public key (JSON object). */
330+ publicKeyJwk: v.any(),
331+ status: v.union(v.literal("active"), v.literal("rotated")),
332+ createdAt: v.number(),
333+ rotatedAt: v.optional(v.number()),
334+ })
335+ .index("by_key_id", ["keyId"])
336+ .index("by_status", ["status"]),
337+
338+ /**
339+ * Dynamic client registrations (RFC 7591) for the anonymous OAuth flow.
340+ * Persisted so MCP clients don't need to re-register after a gateway restart.
341+ */
342+ anonymousOauthClients: defineTable({
343+ clientId: v.string(), // domain ID: anon_client_<uuid>
344+ clientName: v.optional(v.string()),
345+ redirectUris: v.array(v.string()),
346+ createdAt: v.number(),
347+ })
348+ .index("by_client_id", ["clientId"]),
316349 });
executor/install+61−18
@@ -5,7 +5,10 @@ APP="executor" · 4 unmodified lines
55 REPO="${EXECUTOR_REPO:-RhysSullivan/assistant}"
66 INSTALL_DIR="${EXECUTOR_INSTALL_DIR:-$HOME/.executor/bin}"
77 REQUESTED_VERSION="${VERSION:-}"
8+BINARY_PATH=""
89 NO_MODIFY_PATH="0"
10+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
11+LOCAL_BINARY_CANDIDATE="$SCRIPT_DIR/dist/executor"
912
1013 RED='\033[0;31m'
1114 MUTED='\033[0;2m'
@@ -20,6 +23,7 @@ Usage: · 8 unmodified lines
2023
2124 Options:
2225 -v, --version <version> Install specific release tag (with or without leading v)
26+ -b, --binary <path> Install from a local prebuilt binary
2327 --no-modify-path Do not append executor bin directory to shell rc
2428 -h, --help Show this help
2529
@@ -43,6 +47,14 @@ while [[ $# -gt 0 ]]; do · 17 unmodified lines
4347 fi
4448 shift 2
4549 ;;
50+ -b|--binary)
51+ BINARY_PATH="${2:-}"
52+ if [[ -z "$BINARY_PATH" ]]; then
53+ echo -e "${RED}Missing value for --binary${NC}"
54+ exit 1
55+ fi
56+ shift 2
57+ ;;
4658 --no-modify-path)
4759 NO_MODIFY_PATH="1"
4860 shift
@@ -80,30 +92,61 @@ archive_ext="tar.gz" · 31 unmodified lines
8092 filename="${APP}-${os}-${arch}.${archive_ext}"
8193 mkdir -p "$INSTALL_DIR"
8294
83−if [[ -z "$REQUESTED_VERSION" ]]; then
84− url="https://github.com/${REPO}/releases/latest/download/${filename}"
85− version="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')"
86−else
87− version="${REQUESTED_VERSION#v}"
88− url="https://github.com/${REPO}/releases/download/v${version}/${filename}"
89−fi
95+install_local_binary() {
96+ local src="$1"
97+ if [[ ! -f "$src" ]]; then
98+ echo -e "${RED}Local binary not found: ${src}${NC}"
99+ exit 1
100+ fi
101+ cp "$src" "$INSTALL_DIR/$APP"
102+ chmod 755 "$INSTALL_DIR/$APP"
103+}
90104
91−tmp_dir="$(mktemp -d)"
92−trap 'rm -rf "$tmp_dir"' EXIT
105+if [[ -n "$BINARY_PATH" ]]; then
106+ echo -e "${MUTED}Installing ${APP}${NC} from local binary: $BINARY_PATH"
107+ install_local_binary "$BINARY_PATH"
108+else
109+ if [[ -z "$REQUESTED_VERSION" ]]; then
110+ url="https://github.com/${REPO}/releases/latest/download/${filename}"
111+ release_json="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null || true)"
112+ version="$(printf "%s" "$release_json" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')"
113+ if [[ -z "$version" ]]; then
114+ version="latest"
115+ fi
116+ else
117+ version="${REQUESTED_VERSION#v}"
118+ url="https://github.com/${REPO}/releases/download/v${version}/${filename}"
119+ fi
93120
94−echo -e "${MUTED}Installing ${APP}${NC} ${version:-latest}"
95−curl -fsSL "$url" -o "$tmp_dir/$filename"
121+ tmp_dir="$(mktemp -d)"
122+ trap 'rm -rf "$tmp_dir"' EXIT
123+
124+ echo -e "${MUTED}Installing ${APP}${NC} ${version:-latest}"
125+ if ! curl -fsSL "$url" -o "$tmp_dir/$filename"; then
126+ if [[ -f "$LOCAL_BINARY_CANDIDATE" ]]; then
127+ echo -e "${MUTED}Release asset not found; using local binary at ${LOCAL_BINARY_CANDIDATE}${NC}"
128+ install_local_binary "$LOCAL_BINARY_CANDIDATE"
129+ else
130+ echo -e "${RED}Could not download release asset: ${url}${NC}"
131+ echo ""
132+ echo "If you are developing locally, build and install from source:"
133+ echo " bun run --cwd executor build:binary"
134+ echo " bash executor/install --binary executor/dist/executor"
135+ exit 1
136+ fi
137+ else
138+ tar -xzf "$tmp_dir/$filename" -C "$tmp_dir"
96139
97−tar -xzf "$tmp_dir/$filename" -C "$tmp_dir"
140+ if [[ ! -f "$tmp_dir/$APP" ]]; then
141+ echo -e "${RED}Archive did not contain expected binary '${APP}'${NC}"
142+ exit 1
143+ fi
98144
99−if [[ ! -f "$tmp_dir/$APP" ]]; then
100− echo -e "${RED}Archive did not contain expected binary '${APP}'${NC}"
101− exit 1
145+ mv "$tmp_dir/$APP" "$INSTALL_DIR/$APP"
146+ chmod 755 "$INSTALL_DIR/$APP"
147+ fi
102148 fi
103149
104−mv "$tmp_dir/$APP" "$INSTALL_DIR/$APP"
105−chmod 755 "$INSTALL_DIR/$APP"
106−
107150 if [[ "$NO_MODIFY_PATH" != "1" ]]; then
108151 shell_name="$(basename "${SHELL:-}")"
109152 case "$shell_name" in
executor/lib/anonymous-oauth.ts+117−19
@@ -18,6 +18,13 @@ · 17 unmodified lines
1818 * (`anon_<uuid>`) which the gateway resolves via
1919 * `bootstrapAnonymousSession` — identical to the existing anonymous flow
2020 * but now behind a proper Bearer token.
21+ *
22+ * Storage is pluggable via the `OAuthStorage` interface:
23+ * - `InMemoryOAuthStorage` — ephemeral, for tests and single-process dev
24+ * - `ConvexOAuthStorage` — persists keys & client registrations to Convex,
25+ * so tokens survive gateway restarts
26+ *
27+ * Authorization codes are always in-memory (short-lived, single-use).
2128 */
2229
2330 import {
@@ -25,6 +32,7 @@ import { · 1 unmodified line
2532 createLocalJWKSet,
2633 exportJWK,
2734 generateKeyPair,
35+ importJWK,
2836 jwtVerify,
2937 type JWK,
3038 type CryptoKey as JoseCryptoKey,
@@ -58,6 +66,73 @@ export interface AnonOAuthConfig { · 27 unmodified lines
5866 accessTokenTtlSeconds?: number;
5967 /** Authorization code TTL in seconds. Default 120 s. */
6068 codeExpirySeconds?: number;
69+ /** Pluggable storage backend for keys & client registrations. */
70+ storage?: OAuthStorage;
71+}
72+
73+// ---------------------------------------------------------------------------
74+// Storage interface
75+// ---------------------------------------------------------------------------
76+
77+export interface StoredSigningKey {
78+ keyId: string;
79+ algorithm: string;
80+ privateKeyJwk: JWK;
81+ publicKeyJwk: JWK;
82+}
83+
84+export interface OAuthStorage {
85+ /**
86+ * Load the active signing key pair.
87+ * Returns null if no key has been generated yet.
88+ */
89+ getActiveSigningKey(): Promise<StoredSigningKey | null>;
90+
91+ /**
92+ * Store a new signing key pair (rotating any previous active key).
93+ */
94+ storeSigningKey(key: StoredSigningKey): Promise<void>;
95+
96+ /**
97+ * Register a new OAuth client. Returns the registration.
98+ */
99+ registerClient(registration: AnonOAuthClientRegistration): Promise<AnonOAuthClientRegistration>;
100+
101+ /**
102+ * Look up an OAuth client by client_id. Returns null if not found.
103+ */
104+ getClient(clientId: string): Promise<AnonOAuthClientRegistration | null>;
105+}
106+
107+// ---------------------------------------------------------------------------
108+// In-memory storage (for tests and single-process dev)
109+// ---------------------------------------------------------------------------
110+
111+export class InMemoryOAuthStorage implements OAuthStorage {
112+ private signingKey: StoredSigningKey | null = null;
113+ private readonly clients = new Map<string, AnonOAuthClientRegistration>();
114+
115+ async getActiveSigningKey(): Promise<StoredSigningKey | null> {
116+ return this.signingKey;
117+ }
118+
119+ async storeSigningKey(key: StoredSigningKey): Promise<void> {
120+ this.signingKey = key;
121+ }
122+
123+ async registerClient(registration: AnonOAuthClientRegistration): Promise<AnonOAuthClientRegistration> {
124+ this.clients.set(registration.client_id, registration);
125+ return registration;
126+ }
127+
128+ async getClient(clientId: string): Promise<AnonOAuthClientRegistration | null> {
129+ return this.clients.get(clientId) ?? null;
130+ }
131+
132+ /** Test helper: number of registered clients. */
133+ get clientCount(): number {
134+ return this.clients.size;
135+ }
61136 }
62137
63138 // ---------------------------------------------------------------------------
@@ -68,13 +143,12 @@ export class AnonymousOAuthServer { · 4 unmodified lines
68143 private readonly issuer: string;
69144 private readonly accessTokenTtlSeconds: number;
70145 private readonly codeExpirySeconds: number;
146+ private readonly storage: OAuthStorage;
71147
72148 private privateKey!: JoseCryptoKey;
73149 private publicJwk!: JWK;
74150 private keyId!: string;
75151
76− /** In-memory client registrations (ephemeral — survives process lifetime). */
77− private readonly clients = new Map<string, AnonOAuthClientRegistration>();
78152 /** In-memory authorization codes (short-lived, cleaned up on use). */
79153 private readonly codes = new Map<string, AuthorizationCode>();
80154
@@ -82,25 +156,53 @@ export class AnonymousOAuthServer { · 1 unmodified line
82156 this.issuer = config.issuer.replace(/\/+$/, "");
83157 this.accessTokenTtlSeconds = config.accessTokenTtlSeconds ?? 24 * 60 * 60;
84158 this.codeExpirySeconds = config.codeExpirySeconds ?? 120;
159+ this.storage = config.storage ?? new InMemoryOAuthStorage();
85160 }
86161
87162 // -------------------------------------------------------------------------
88163 // Initialization
89164 // -------------------------------------------------------------------------
90165
91− /** Generate an ephemeral RSA key pair. Must be called once before use. */
166+ /**
167+ * Initialize the OAuth server.
168+ *
169+ * Attempts to load an existing signing key from storage. If none exists,
170+ * generates a new RSA key pair and persists it. Either way, the private
171+ * key and public JWK are cached locally for fast in-process operations.
172+ */
92173 async init(): Promise<void> {
174+ const existing = await this.storage.getActiveSigningKey();
175+
176+ if (existing) {
177+ // Import the persisted key pair into CryptoKey objects
178+ this.keyId = existing.keyId;
179+ this.publicJwk = { ...existing.publicKeyJwk, kid: this.keyId, use: "sig", alg: existing.algorithm };
180+ this.privateKey = await importJWK(existing.privateKeyJwk, existing.algorithm) as JoseCryptoKey;
181+ return;
182+ }
183+
184+ // No existing key — generate a fresh pair and persist it
93185 const { privateKey, publicKey } = await generateKeyPair("RS256");
94186 this.privateKey = privateKey;
95− this.keyId = `anon-${crypto.randomUUID().slice(0, 8)}`;
96− const jwk = await exportJWK(publicKey);
97− this.publicJwk = { ...jwk, kid: this.keyId, use: "sig", alg: "RS256" };
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+ });
98200 }
99201
100− /** Import an existing key pair (for tests or persistence). */
202+ /** Import an existing key pair (for tests or manual persistence). */
101203 async initWithKeys(privateKey: JoseCryptoKey, publicJwk: JWK): Promise<void> {
102204 this.privateKey = privateKey;
103− this.keyId = publicJwk.kid ?? `anon-${crypto.randomUUID().slice(0, 8)}`;
205+ this.keyId = publicJwk.kid ?? `anon_key_${crypto.randomUUID().slice(0, 8)}`;
104206 this.publicJwk = { ...publicJwk, kid: this.keyId, use: "sig", alg: "RS256" };
105207 }
106208
@@ -135,10 +237,10 @@ export class AnonymousOAuthServer { · 28 unmodified lines
135237 // RFC 7591 — Dynamic Client Registration
136238 // -------------------------------------------------------------------------
137239
138− registerClient(body: {
240+ async registerClient(body: {
139241 redirect_uris?: string[];
140242 client_name?: string;
141− }): AnonOAuthClientRegistration {
243+ }): Promise<AnonOAuthClientRegistration> {
142244 const redirectUris = body.redirect_uris;
143245 if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
144246 throw new OAuthBadRequest("redirect_uris is required and must be non-empty");
@@ -157,8 +259,8 @@ export class AnonymousOAuthServer { · 12 unmodified lines
157259 redirect_uris: redirectUris,
158260 created_at: Date.now(),
159261 };
160− this.clients.set(clientId, registration);
161− return registration;
262+
263+ return await this.storage.registerClient(registration);
162264 }
163265
164266 // -------------------------------------------------------------------------
@@ -170,7 +272,7 @@ export class AnonymousOAuthServer { · 5 unmodified lines
170272 * user to authenticate — we auto-approve and redirect back immediately
171273 * with an authorization code.
172274 */
173− authorize(params: URLSearchParams): { redirectTo: string } {
275+ async authorize(params: URLSearchParams): Promise<{ redirectTo: string }> {
174276 const responseType = params.get("response_type");
175277 if (responseType !== "code") {
176278 throw new OAuthBadRequest("response_type must be 'code'");
@@ -181,7 +283,7 @@ export class AnonymousOAuthServer { · 4 unmodified lines
181283 throw new OAuthBadRequest("client_id is required");
182284 }
183285
184− const client = this.clients.get(clientId);
286+ const client = await this.storage.getClient(clientId);
185287 if (!client) {
186288 throw new OAuthBadRequest("Unknown client_id");
187289 }
@@ -203,7 +305,7 @@ export class AnonymousOAuthServer { · 15 unmodified lines
203305 // Generate anonymous identity
204306 const actorId = `anon_${crypto.randomUUID()}`;
205307
206− // Issue authorization code
308+ // Issue authorization code (always in-memory — short-lived, single-use)
207309 const code = crypto.randomUUID();
208310 this.codes.set(code, {
209311 code,
@@ -331,10 +433,6 @@ export class AnonymousOAuthServer { · 121 unmodified lines
331433 return this.issuer;
332434 }
333435
334− getClientCount(): number {
335− return this.clients.size;
336− }
337−
338436 getCodeCount(): number {
339437 return this.codes.size;
340438 }