Unified · Split
| @@ -178,8 +178,21 @@ From `executor/`: · 177 unmodified lines | ||
| 178 | 178 | |
| 179 | 179 | ```bash |
| 180 | 180 | bun test |
| 181 | +bun run sandbox:install:session | |
| 181 | 182 | ``` |
| 182 | 183 | |
| 184 | +`sandbox:install:session` starts a fresh Vercel sandbox, runs `curl -fsSL https://executor.sh/install | bash`, and prints externally reachable URLs for: | |
| 185 | + | |
| 186 | +- Web UI | |
| 187 | +- Convex API port | |
| 188 | +- Convex Site (MCP) port (`/mcp`, `/mcp/anonymous`) | |
| 189 | + | |
| 190 | +Useful environment overrides: | |
| 191 | + | |
| 192 | +- `EXECUTOR_SANDBOX_TIMEOUT_MS` (default `1800000`) | |
| 193 | +- `EXECUTOR_SANDBOX_INSTALL_TIMEOUT_MS` (default `900000`) | |
| 194 | +- `EXECUTOR_BACKEND_PORT`, `EXECUTOR_BACKEND_SITE_PORT`, `EXECUTOR_WEB_PORT` | |
| 195 | + | |
| 183 | 196 | From repo root: |
| 184 | 197 | |
| 185 | 198 | ```bash |
| @@ -1,102 +0,0 @@ | ||
| 1 | −import type { NextRequest } from "next/server"; | |
| 2 | −import { parse as parseDomain } from "tldts"; | |
| 3 | − | |
| 4 | −const MAX_REDIRECTS = 4; | |
| 5 | − | |
| 6 | −function isAllowedFaviconUrl(url: URL): boolean { | |
| 7 | − if (url.protocol !== "https:") { | |
| 8 | − return false; | |
| 9 | − } | |
| 10 | − | |
| 11 | − const hostname = url.hostname.toLowerCase(); | |
| 12 | − | |
| 13 | − if (hostname === "icons.duckduckgo.com") { | |
| 14 | − return url.pathname.startsWith("/ip3/") && url.pathname.endsWith(".ico"); | |
| 15 | − } | |
| 16 | − | |
| 17 | − if (hostname === "www.google.com") { | |
| 18 | − return url.pathname === "/s2/favicons"; | |
| 19 | − } | |
| 20 | − | |
| 21 | − if (/^t\d+\.gstatic\.com$/.test(hostname)) { | |
| 22 | − return url.pathname === "/faviconV2"; | |
| 23 | − } | |
| 24 | − | |
| 25 | − if (url.pathname !== "/favicon.ico" || url.search.length > 0) { | |
| 26 | − return false; | |
| 27 | − } | |
| 28 | − | |
| 29 | − const parsed = parseDomain(hostname); | |
| 30 | − return Boolean(parsed.isIcann) && !parsed.isIp; | |
| 31 | −} | |
| 32 | − | |
| 33 | −async function fetchFaviconWithRedirects(initialUrl: URL): Promise<Response> { | |
| 34 | − let currentUrl = initialUrl; | |
| 35 | − | |
| 36 | − for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) { | |
| 37 | − const response = await fetch(currentUrl, { | |
| 38 | − redirect: "manual", | |
| 39 | − headers: { | |
| 40 | − accept: "image/*,*/*;q=0.8", | |
| 41 | − "user-agent": "executor-web-favicon-proxy/1.0", | |
| 42 | − }, | |
| 43 | − cache: "force-cache", | |
| 44 | − }); | |
| 45 | − | |
| 46 | − if (response.status >= 300 && response.status < 400) { | |
| 47 | − const location = response.headers.get("location"); | |
| 48 | − if (!location) { | |
| 49 | − return response; | |
| 50 | − } | |
| 51 | − | |
| 52 | − const nextUrl = new URL(location, currentUrl); | |
| 53 | − if (!isAllowedFaviconUrl(nextUrl)) { | |
| 54 | − return new Response("Blocked redirect target", { status: 400 }); | |
| 55 | − } | |
| 56 | − | |
| 57 | − currentUrl = nextUrl; | |
| 58 | − continue; | |
| 59 | − } | |
| 60 | − | |
| 61 | − return response; | |
| 62 | − } | |
| 63 | − | |
| 64 | − return new Response("Too many redirects", { status: 508 }); | |
| 65 | −} | |
| 66 | − | |
| 67 | −export async function GET(request: NextRequest): Promise<Response> { | |
| 68 | − const rawUrl = request.nextUrl.searchParams.get("url")?.trim() ?? ""; | |
| 69 | − if (!rawUrl) { | |
| 70 | − return new Response("Missing url", { status: 400 }); | |
| 71 | − } | |
| 72 | − | |
| 73 | − let upstreamUrl: URL; | |
| 74 | − try { | |
| 75 | − upstreamUrl = new URL(rawUrl); | |
| 76 | − } catch { | |
| 77 | − return new Response("Invalid url", { status: 400 }); | |
| 78 | − } | |
| 79 | − | |
| 80 | − if (!isAllowedFaviconUrl(upstreamUrl)) { | |
| 81 | − return new Response("Blocked url", { status: 400 }); | |
| 82 | − } | |
| 83 | − | |
| 84 | − let upstreamResponse: Response; | |
| 85 | − try { | |
| 86 | − upstreamResponse = await fetchFaviconWithRedirects(upstreamUrl); | |
| 87 | − } catch { | |
| 88 | − return new Response("Failed to fetch favicon", { status: 502 }); | |
| 89 | − } | |
| 90 | − | |
| 91 | − const headers = new Headers(); | |
| 92 | − const contentType = upstreamResponse.headers.get("content-type"); | |
| 93 | − if (contentType) { | |
| 94 | − headers.set("content-type", contentType); | |
| 95 | − } | |
| 96 | − headers.set("cache-control", "public, max-age=86400, stale-while-revalidate=604800"); | |
| 97 | − | |
| 98 | − return new Response(upstreamResponse.body, { | |
| 99 | − status: upstreamResponse.status, | |
| 100 | − headers, | |
| 101 | − }); | |
| 102 | −} | |
| @@ -1,13 +1,10 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useEffect, useMemo, useState } from "react"; | |
| 3 | +import { useEffect, useState } from "react"; | |
| 4 | 4 | import { Layers, Globe, Server } from "lucide-react"; |
| 5 | +import Image from "next/image"; | |
| 5 | 6 | import type { ToolSourceRecord } from "@/lib/types"; |
| 6 | −import { | |
| 7 | − getSourceFaviconCandidates, | |
| 8 | − getSourceFaviconCandidatesForSource, | |
| 9 | − getSourceFaviconProxyUrl, | |
| 10 | −} from "@/lib/tools/source-helpers"; | |
| 7 | +import { getSourceFavicon, getSourceFaviconUrl } from "@/lib/tools/source-helpers"; | |
| 11 | 8 | |
| 12 | 9 | interface SourceFaviconProps { |
| 13 | 10 | source?: ToolSourceRecord; |
| @@ -36,58 +33,32 @@ export function SourceFavicon({ · 22 unmodified lines | ||
| 36 | 33 | imageSize = 20, |
| 37 | 34 | fallbackType, |
| 38 | 35 | }: SourceFaviconProps) { |
| 39 | − const sourceFaviconCandidates = useMemo(() => { | |
| 40 | − if (sourceUrl) { | |
| 41 | − return getSourceFaviconCandidates(sourceUrl); | |
| 42 | − } | |
| 43 | − | |
| 44 | − return source | |
| 45 | − ? getSourceFaviconCandidatesForSource(source) | |
| 46 | − : []; | |
| 47 | − }, [ | |
| 48 | − source?.id, | |
| 49 | − source?.type, | |
| 50 | − source?.name, | |
| 51 | − sourceUrl, | |
| 52 | − source?.config?.url, | |
| 53 | − source?.config?.endpoint, | |
| 54 | − source?.config?.baseUrl, | |
| 55 | − source?.config?.collectionUrl, | |
| 56 | − source?.config?.specUrl, | |
| 57 | − source?.config?.spec, | |
| 58 | − ]); | |
| 59 | − | |
| 60 | − const sourceFaviconCandidateKey = sourceFaviconCandidates.join("|"); | |
| 61 | − const [faviconIndex, setFaviconIndex] = useState(0); | |
| 36 | + const sourceFavicon = sourceUrl | |
| 37 | + ? getSourceFaviconUrl(sourceUrl) | |
| 38 | + : source | |
| 39 | + ? getSourceFavicon(source) | |
| 40 | + : null; | |
| 41 | + const [failed, setFailed] = useState(false); | |
| 62 | 42 | |
| 63 | 43 | useEffect(() => { |
| 64 | − setFaviconIndex(0); | |
| 65 | − }, [sourceFaviconCandidateKey]); | |
| 44 | + setFailed(false); | |
| 45 | + }, [sourceFavicon]); | |
| 66 | 46 | |
| 67 | − const sourceFavicon = sourceFaviconCandidates[faviconIndex] ?? null; | |
| 68 | − const sourceFaviconSrc = sourceFavicon ? getSourceFaviconProxyUrl(sourceFavicon) : null; | |
| 69 | − const hasExhaustedCandidates = faviconIndex >= sourceFaviconCandidates.length; | |
| 70 | − | |
| 71 | − if (!sourceFaviconSrc || hasExhaustedCandidates) { | |
| 47 | + if (!sourceFavicon || failed) { | |
| 72 | 48 | const sourceType = fallbackType ?? source?.type ?? "openapi"; |
| 73 | 49 | return <DefaultSourceIcon type={sourceType} className={iconClassName} />; |
| 74 | 50 | } |
| 75 | 51 | |
| 76 | − const handleFaviconError = () => { | |
| 77 | − setFaviconIndex((current) => { | |
| 78 | − return current + 1; | |
| 79 | − }); | |
| 80 | − }; | |
| 81 | − | |
| 82 | 52 | return ( |
| 83 | − <img | |
| 84 | − src={sourceFaviconSrc} | |
| 53 | + <Image | |
| 54 | + src={sourceFavicon} | |
| 85 | 55 | alt="" |
| 86 | 56 | width={imageSize} |
| 87 | 57 | height={imageSize} |
| 88 | 58 | className={imageClassName ?? "w-full h-full object-contain"} |
| 89 | 59 | loading="lazy" |
| 90 | − onError={handleFaviconError} | |
| 60 | + unoptimized | |
| 61 | + onError={() => setFailed(true)} | |
| 91 | 62 | /> |
| 92 | 63 | ); |
| 93 | 64 | } |
| @@ -1,47 +0,0 @@ | ||
| 1 | −import { describe, expect, test } from "bun:test"; | |
| 2 | −import { getSourceFaviconCandidates, getSourceFaviconProxyUrl } from "./source-helpers"; | |
| 3 | − | |
| 4 | −describe("getSourceFaviconCandidates", () => { | |
| 5 | − test("extracts api host from raw spec paths", () => { | |
| 6 | − const candidates = getSourceFaviconCandidates("https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml"); | |
| 7 | − | |
| 8 | − expect(candidates[0]).toBe("https://raw.githubusercontent.com/favicon.ico"); | |
| 9 | − expect(candidates).toEqual( | |
| 10 | − expect.arrayContaining([ | |
| 11 | − "https://icons.duckduckgo.com/ip3/raw.githubusercontent.com.ico", | |
| 12 | − "https://www.google.com/s2/favicons?domain=raw.githubusercontent.com&sz=32", | |
| 13 | − "https://icons.duckduckgo.com/ip3/githubusercontent.com.ico", | |
| 14 | − "https://www.google.com/s2/favicons?domain=githubusercontent.com&sz=32", | |
| 15 | − "https://icons.duckduckgo.com/ip3/api.github.com.ico", | |
| 16 | − "https://www.google.com/s2/favicons?domain=api.github.com&sz=32", | |
| 17 | − ]), | |
| 18 | − ); | |
| 19 | − }); | |
| 20 | − | |
| 21 | − test("returns empty list for malformed urls", () => { | |
| 22 | − expect(getSourceFaviconCandidates("not a url")).toEqual([]); | |
| 23 | − }); | |
| 24 | − | |
| 25 | − test("ignores non-domain-like path fragments", () => { | |
| 26 | − const candidates = getSourceFaviconCandidates("https://api.example.org/docs/v3.2/openapi.3.2.json"); | |
| 27 | − | |
| 28 | − expect(candidates).toEqual( | |
| 29 | − expect.not.arrayContaining([ | |
| 30 | − "https://icons.duckduckgo.com/ip3/v3.2.ico", | |
| 31 | − "https://icons.duckduckgo.com/ip3/api.2.ico", | |
| 32 | − ]), | |
| 33 | − ); | |
| 34 | − }); | |
| 35 | − | |
| 36 | − test("creates local proxy url for remote favicons", () => { | |
| 37 | − expect(getSourceFaviconProxyUrl("https://example.com/favicon.ico")).toBe( | |
| 38 | − "/api/favicon?url=https%3A%2F%2Fexample.com%2Ffavicon.ico", | |
| 39 | − ); | |
| 40 | − }); | |
| 41 | − | |
| 42 | − test("preserves already-local favicon urls", () => { | |
| 43 | − expect(getSourceFaviconProxyUrl("/api/favicon?url=https%3A%2F%2Fexample.com%2Ffavicon.ico")).toBe( | |
| 44 | − "/api/favicon?url=https%3A%2F%2Fexample.com%2Ffavicon.ico", | |
| 45 | − ); | |
| 46 | − }); | |
| 47 | −}); | |
| @@ -16,121 +16,17 @@ const RAW_HOSTS = new Set([ · 15 unmodified lines | ||
| 16 | 16 | "raw.github.com", |
| 17 | 17 | ]); |
| 18 | 18 | |
| 19 | −function normalizeFaviconDomain(value: string): string { | |
| 20 | − return (value || "").toLowerCase().trim(); | |
| 21 | −} | |
| 22 | − | |
| 23 | −function uniqueFaviconUrls(urls: string[]): string[] { | |
| 24 | − const dedupe = new Set<string>(); | |
| 25 | − const result: string[] = []; | |
| 26 | − | |
| 27 | − for (const value of urls) { | |
| 28 | − const normalized = value.trim(); | |
| 29 | − if (normalized.length === 0 || dedupe.has(normalized)) { | |
| 30 | − continue; | |
| 31 | − } | |
| 32 | − dedupe.add(normalized); | |
| 33 | − result.push(normalized); | |
| 34 | − } | |
| 35 | − | |
| 36 | − return result; | |
| 37 | −} | |
| 38 | − | |
| 39 | −function faviconHostCandidates(hostname: string): string[] { | |
| 40 | − const normalized = normalizeFaviconDomain(hostname); | |
| 41 | − if (normalized.length === 0) { | |
| 42 | − return []; | |
| 43 | − } | |
| 44 | − | |
| 45 | − const parts = normalized.split(".").filter(Boolean); | |
| 46 | − if (parts.length === 0) { | |
| 47 | − return []; | |
| 48 | − } | |
| 49 | − | |
| 50 | − const candidates = new Set<string>([normalized]); | |
| 51 | − for (let i = 1; i < parts.length; i += 1) { | |
| 52 | − const suffix = parts.slice(i).join("."); | |
| 53 | − if (suffix.includes(".")) { | |
| 54 | − candidates.add(suffix); | |
| 55 | − } | |
| 56 | − } | |
| 57 | − | |
| 58 | − return Array.from(candidates); | |
| 59 | −} | |
| 60 | − | |
| 61 | −function candidateHostsFromPath(pathname: string): string[] { | |
| 62 | − const trimmed = normalizeFaviconDomain(pathname); | |
| 63 | − if (trimmed.length === 0) { | |
| 64 | − return []; | |
| 65 | − } | |
| 66 | − | |
| 67 | − const segments = trimmed.split("/").filter(Boolean); | |
| 68 | − const suffixes = [".json", ".yaml", ".yml", ".xml", ".txt", ".html"]; | |
| 69 | − const candidates = new Set<string>(); | |
| 70 | − | |
| 71 | − for (const segment of segments) { | |
| 72 | − if (!segment.includes(".")) { | |
| 73 | − continue; | |
| 74 | − } | |
| 75 | − | |
| 76 | − let normalized = segment; | |
| 77 | − for (const suffix of suffixes) { | |
| 78 | − if (normalized.endsWith(suffix)) { | |
| 79 | − normalized = normalized.slice(0, -suffix.length); | |
| 80 | − break; | |
| 81 | − } | |
| 82 | − } | |
| 83 | − | |
| 84 | − const parsed = parseDomain(normalized); | |
| 85 | − if (!parsed.isIcann || !parsed.domain || !parsed.domainWithoutSuffix) { | |
| 86 | − continue; | |
| 87 | − } | |
| 88 | − | |
| 89 | − candidates.add(normalizeFaviconDomain(normalized)); | |
| 90 | − } | |
| 91 | − | |
| 92 | − return Array.from(candidates); | |
| 93 | −} | |
| 94 | − | |
| 95 | −export function getSourceFaviconCandidates(url: string | undefined | null): string[] { | |
| 96 | − if (!url) return []; | |
| 19 | +export function getSourceFaviconUrl(url: string | undefined | null): string | null { | |
| 20 | + if (!url) return null; | |
| 97 | 21 | try { |
| 98 | 22 | const hostname = new URL(url).hostname; |
| 99 | − const sourceUrl = new URL(url); | |
| 100 | − const hostCandidates = [ | |
| 101 | − ...faviconHostCandidates(hostname), | |
| 102 | − ...candidateHostsFromPath(sourceUrl.pathname), | |
| 103 | − ]; | |
| 104 | − | |
| 105 | − const candidateUrls: string[] = [`${sourceUrl.origin}/favicon.ico`]; | |
| 106 | − for (const host of hostCandidates) { | |
| 107 | − candidateUrls.push( | |
| 108 | − `https://icons.duckduckgo.com/ip3/${encodeURIComponent(host)}.ico`, | |
| 109 | − `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=32`, | |
| 110 | − ); | |
| 111 | − } | |
| 23 | + const parsed = parseDomain(hostname); | |
| 24 | + const domain = parsed.domain ?? hostname; | |
| 112 | 25 | |
| 113 | − return uniqueFaviconUrls(candidateUrls); | |
| 26 | + return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`; | |
| 114 | 27 | } catch { |
| 115 | − return []; | |
| 116 | − } | |
| 117 | −} | |
| 118 | − | |
| 119 | −export function getSourceFaviconUrl(url: string | undefined | null): string | null { | |
| 120 | − return getSourceFaviconCandidates(url)[0] ?? null; | |
| 121 | −} | |
| 122 | − | |
| 123 | −export function getSourceFaviconProxyUrl(url: string): string { | |
| 124 | − const trimmed = url.trim(); | |
| 125 | − if (!trimmed) { | |
| 126 | − return ""; | |
| 127 | − } | |
| 128 | − | |
| 129 | − if (trimmed.startsWith("/")) { | |
| 130 | − return trimmed; | |
| 28 | + return null; | |
| 131 | 29 | } |
| 132 | − | |
| 133 | − return `/api/favicon?url=${encodeURIComponent(trimmed)}`; | |
| 134 | 30 | } |
| 135 | 31 | |
| 136 | 32 | function sourceFaviconSourceUrl(source: ToolSourceRecord): string | null { |
| @@ -164,11 +60,6 @@ function sourceFaviconSourceUrl(source: ToolSourceRecord): string | null { · 27 unmodified lines | ||
| 164 | 60 | ?? parseOrigin(spec); |
| 165 | 61 | } |
| 166 | 62 | |
| 167 | −export function getSourceFaviconCandidatesForSource(source: ToolSourceRecord): string[] { | |
| 168 | − const sourceUrl = sourceFaviconSourceUrl(source); | |
| 169 | − return getSourceFaviconCandidates(sourceUrl); | |
| 170 | −} | |
| 171 | − | |
| 172 | 63 | export function getSourceFavicon(source: ToolSourceRecord): string | null { |
| 173 | 64 | const sourceUrl = sourceFaviconSourceUrl(source); |
| 174 | 65 | return sourceUrl ? getSourceFaviconUrl(sourceUrl) : null; |
| @@ -28,6 +28,7 @@ · 27 unmodified lines | ||
| 28 | 28 | "codegen": "bunx convex codegen", |
| 29 | 29 | "test:e2e:install": "bun test scripts/e2e/install.e2e.test.ts", |
| 30 | 30 | "test:e2e:install:sandbox": "bun test scripts/e2e/install.sandbox.e2e.test.ts", |
| 31 | + "sandbox:install:session": "bun run scripts/e2e/install.sandbox.session.ts", | |
| 31 | 32 | "build:binary": "bun build executor.ts --compile --outfile dist/executor", |
| 32 | 33 | "build:release": "bun run scripts/release/build-release.ts" |
| 33 | 34 | }, |
| @@ -0,0 +1,155 @@ | ||
| 1 | +import { Sandbox } from "@vercel/sandbox"; | |
| 2 | + | |
| 3 | +type CommandResult = { | |
| 4 | + exitCode: number; | |
| 5 | + stdout: string; | |
| 6 | + stderr: string; | |
| 7 | +}; | |
| 8 | + | |
| 9 | +function parseIntegerEnv(name: string, fallback: number): number { | |
| 10 | + const raw = process.env[name]; | |
| 11 | + if (!raw) { | |
| 12 | + return fallback; | |
| 13 | + } | |
| 14 | + | |
| 15 | + const parsed = Number(raw); | |
| 16 | + if (!Number.isInteger(parsed) || parsed <= 0) { | |
| 17 | + throw new Error(`Expected ${name} to be a positive integer, got: ${raw}`); | |
| 18 | + } | |
| 19 | + | |
| 20 | + return parsed; | |
| 21 | +} | |
| 22 | + | |
| 23 | +function sandboxCredentials(): { token: string; teamId: string; projectId: string } | Record<never, never> { | |
| 24 | + if (process.env.VERCEL_TOKEN && process.env.VERCEL_TEAM_ID && process.env.VERCEL_PROJECT_ID) { | |
| 25 | + return { | |
| 26 | + token: process.env.VERCEL_TOKEN, | |
| 27 | + teamId: process.env.VERCEL_TEAM_ID, | |
| 28 | + projectId: process.env.VERCEL_PROJECT_ID, | |
| 29 | + }; | |
| 30 | + } | |
| 31 | + | |
| 32 | + return {}; | |
| 33 | +} | |
| 34 | + | |
| 35 | +async function runSandboxBash( | |
| 36 | + sandbox: Sandbox, | |
| 37 | + script: string, | |
| 38 | + options: { | |
| 39 | + timeoutMs: number; | |
| 40 | + env?: Record<string, string>; | |
| 41 | + }, | |
| 42 | +): Promise<CommandResult> { | |
| 43 | + const controller = new AbortController(); | |
| 44 | + const timeout = setTimeout(() => { | |
| 45 | + controller.abort(); | |
| 46 | + }, options.timeoutMs); | |
| 47 | + | |
| 48 | + try { | |
| 49 | + const command = await sandbox.runCommand({ | |
| 50 | + cmd: "bash", | |
| 51 | + args: ["-lc", script], | |
| 52 | + env: options.env, | |
| 53 | + signal: controller.signal, | |
| 54 | + }); | |
| 55 | + | |
| 56 | + const [stdout, stderr] = await Promise.all([command.stdout(), command.stderr()]); | |
| 57 | + return { | |
| 58 | + exitCode: command.exitCode, | |
| 59 | + stdout, | |
| 60 | + stderr, | |
| 61 | + }; | |
| 62 | + } finally { | |
| 63 | + clearTimeout(timeout); | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +function assertSuccess(result: CommandResult, label: string): void { | |
| 68 | + if (result.exitCode === 0) { | |
| 69 | + return; | |
| 70 | + } | |
| 71 | + | |
| 72 | + throw new Error( | |
| 73 | + [ | |
| 74 | + `${label} failed with exit code ${result.exitCode}`, | |
| 75 | + `stdout:\n${result.stdout}`, | |
| 76 | + `stderr:\n${result.stderr}`, | |
| 77 | + ].join("\n\n"), | |
| 78 | + ); | |
| 79 | +} | |
| 80 | + | |
| 81 | +const backendPort = parseIntegerEnv("EXECUTOR_BACKEND_PORT", 5410); | |
| 82 | +const sitePort = parseIntegerEnv("EXECUTOR_BACKEND_SITE_PORT", 5411); | |
| 83 | +const webPort = parseIntegerEnv("EXECUTOR_WEB_PORT", 5312); | |
| 84 | +const sandboxTimeoutMs = parseIntegerEnv("EXECUTOR_SANDBOX_TIMEOUT_MS", 30 * 60 * 1000); | |
| 85 | +const installTimeoutMs = parseIntegerEnv("EXECUTOR_SANDBOX_INSTALL_TIMEOUT_MS", 15 * 60 * 1000); | |
| 86 | + | |
| 87 | +let sandbox: Sandbox | null = null; | |
| 88 | + | |
| 89 | +try { | |
| 90 | + sandbox = await Sandbox.create({ | |
| 91 | + runtime: "node22", | |
| 92 | + ports: [webPort, backendPort, sitePort], | |
| 93 | + timeout: sandboxTimeoutMs, | |
| 94 | + ...sandboxCredentials(), | |
| 95 | + }); | |
| 96 | +} catch (error) { | |
| 97 | + const message = error instanceof Error ? error.message : String(error); | |
| 98 | + throw new Error( | |
| 99 | + `Could not create Vercel sandbox. Configure auth via VERCEL_TOKEN/VERCEL_TEAM_ID/VERCEL_PROJECT_ID or VERCEL_OIDC_TOKEN.\n\n${message}`, | |
| 100 | + ); | |
| 101 | +} | |
| 102 | + | |
| 103 | +try { | |
| 104 | + console.log(`[sandbox] created: ${sandbox.sandboxId}`); | |
| 105 | + console.log("[sandbox] running executor.sh install flow..."); | |
| 106 | + | |
| 107 | + const install = await runSandboxBash( | |
| 108 | + sandbox, | |
| 109 | + [ | |
| 110 | + "set -euo pipefail", | |
| 111 | + "cd ~", | |
| 112 | + "if [ -x ~/.executor/bin/executor ]; then ~/.executor/bin/executor uninstall --yes || true; fi", | |
| 113 | + "rm -rf ~/.executor", | |
| 114 | + "curl -fsSL https://executor.sh/install | bash -s -- --no-modify-path --no-star-prompt", | |
| 115 | + "~/.executor/bin/executor doctor --verbose", | |
| 116 | + ].join("; "), | |
| 117 | + { | |
| 118 | + timeoutMs: installTimeoutMs, | |
| 119 | + env: { | |
| 120 | + EXECUTOR_BACKEND_INTERFACE: "0.0.0.0", | |
| 121 | + EXECUTOR_WEB_INTERFACE: "0.0.0.0", | |
| 122 | + EXECUTOR_BACKEND_PORT: String(backendPort), | |
| 123 | + EXECUTOR_BACKEND_SITE_PORT: String(sitePort), | |
| 124 | + EXECUTOR_WEB_PORT: String(webPort), | |
| 125 | + }, | |
| 126 | + }, | |
| 127 | + ); | |
| 128 | + assertSuccess(install, "sandbox install + doctor"); | |
| 129 | + | |
| 130 | + const webUrl = sandbox.domain(webPort); | |
| 131 | + const convexUrl = sandbox.domain(backendPort); | |
| 132 | + const convexSiteUrl = sandbox.domain(sitePort); | |
| 133 | + | |
| 134 | + console.log(""); | |
| 135 | + console.log("Sandbox is ready for manual testing."); | |
| 136 | + console.log(`Sandbox ID: ${sandbox.sandboxId}`); | |
| 137 | + console.log(`Web UI: ${webUrl}`); | |
| 138 | + console.log(`Convex API: ${convexUrl}`); | |
| 139 | + console.log(`Convex Site: ${convexSiteUrl}`); | |
| 140 | + console.log(`MCP (auth): ${convexSiteUrl}/mcp`); | |
| 141 | + console.log(`MCP (anonymous): ${convexSiteUrl}/mcp/anonymous`); | |
| 142 | + console.log(""); | |
| 143 | + console.log("The sandbox is left running so you can test from your machine."); | |
| 144 | + console.log("Stop it from Vercel when you are done, or wait for sandbox timeout."); | |
| 145 | +} catch (error) { | |
| 146 | + if (sandbox) { | |
| 147 | + try { | |
| 148 | + await sandbox.stop(); | |
| 149 | + console.error("[sandbox] install failed; sandbox has been stopped."); | |
| 150 | + } catch { | |
| 151 | + console.error("[sandbox] install failed and sandbox cleanup also failed."); | |
| 152 | + } | |
| 153 | + } | |
| 154 | + throw error; | |
| 155 | +} | |
| @@ -5,6 +5,11 @@ · 4 unmodified lines | ||
| 5 | 5 | "type": "remote", |
| 6 | 6 | "url": "https://www.answeroverflow.com/mcp", |
| 7 | 7 | "enabled": true |
| 8 | + }, | |
| 9 | + "oracle": { | |
| 10 | + "type": "local", | |
| 11 | + "command": ["npx", "-y", "@steipete/oracle", "oracle-mcp"], | |
| 12 | + "enabled": true | |
| 8 | 13 | } |
| 9 | 14 | }, |
| 10 | 15 | "agent":{ |