Unified · Split
| @@ -178,8 +178,21 @@ From `executor/`: · 177 unmodified lines | |||
| 178 | 178 | ||
| 179 | ```bash | 179 | ```bash |
| 180 | bun test | 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 | From repo root: | 196 | From repo root: |
| 184 | 197 | ||
| 185 | ```bash | 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 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useEffect, useMemo, useState } from "react"; | 3 | import { useEffect, useState } from "react"; |
| 4 | import { Layers, Globe, Server } from "lucide-react"; | 4 | import { Layers, Globe, Server } from "lucide-react"; |
| 5 | import Image from "next/image"; | ||
| 5 | import type { ToolSourceRecord } from "@/lib/types"; | 6 | import type { ToolSourceRecord } from "@/lib/types"; |
| 6 | import { | 7 | import { getSourceFavicon, getSourceFaviconUrl } from "@/lib/tools/source-helpers"; |
| 7 | getSourceFaviconCandidates, | ||
| 8 | getSourceFaviconCandidatesForSource, | ||
| 9 | getSourceFaviconProxyUrl, | ||
| 10 | } from "@/lib/tools/source-helpers"; | ||
| 11 | 8 | ||
| 12 | interface SourceFaviconProps { | 9 | interface SourceFaviconProps { |
| 13 | source?: ToolSourceRecord; | 10 | source?: ToolSourceRecord; |
| @@ -36,58 +33,32 @@ export function SourceFavicon({ · 22 unmodified lines | |||
| 36 | imageSize = 20, | 33 | imageSize = 20, |
| 37 | fallbackType, | 34 | fallbackType, |
| 38 | }: SourceFaviconProps) { | 35 | }: SourceFaviconProps) { |
| 39 | const sourceFaviconCandidates = useMemo(() => { | 36 | const sourceFavicon = sourceUrl |
| 40 | if (sourceUrl) { | 37 | ? getSourceFaviconUrl(sourceUrl) |
| 41 | return getSourceFaviconCandidates(sourceUrl); | 38 | : source |
| 42 | } | 39 | ? getSourceFavicon(source) |
| 43 | 40 | : null; | |
| 44 | return source | 41 | const [failed, setFailed] = useState(false); |
| 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); | ||
| 62 | 42 | ||
| 63 | useEffect(() => { | 43 | useEffect(() => { |
| 64 | setFaviconIndex(0); | 44 | setFailed(false); |
| 65 | }, [sourceFaviconCandidateKey]); | 45 | }, [sourceFavicon]); |
| 66 | 46 | ||
| 67 | const sourceFavicon = sourceFaviconCandidates[faviconIndex] ?? null; | 47 | if (!sourceFavicon || failed) { |
| 68 | const sourceFaviconSrc = sourceFavicon ? getSourceFaviconProxyUrl(sourceFavicon) : null; | ||
| 69 | const hasExhaustedCandidates = faviconIndex >= sourceFaviconCandidates.length; | ||
| 70 | |||
| 71 | if (!sourceFaviconSrc || hasExhaustedCandidates) { | ||
| 72 | const sourceType = fallbackType ?? source?.type ?? "openapi"; | 48 | const sourceType = fallbackType ?? source?.type ?? "openapi"; |
| 73 | return <DefaultSourceIcon type={sourceType} className={iconClassName} />; | 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 | return ( | 52 | return ( |
| 83 | <img | 53 | <Image |
| 84 | src={sourceFaviconSrc} | 54 | src={sourceFavicon} |
| 85 | alt="" | 55 | alt="" |
| 86 | width={imageSize} | 56 | width={imageSize} |
| 87 | height={imageSize} | 57 | height={imageSize} |
| 88 | className={imageClassName ?? "w-full h-full object-contain"} | 58 | className={imageClassName ?? "w-full h-full object-contain"} |
| 89 | loading="lazy" | 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 | "raw.github.com", | 16 | "raw.github.com", |
| 17 | ]); | 17 | ]); |
| 18 | 18 | ||
| 19 | function normalizeFaviconDomain(value: string): string { | 19 | export function getSourceFaviconUrl(url: string | undefined | null): string | null { |
| 20 | return (value || "").toLowerCase().trim(); | 20 | if (!url) return null; |
| 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 []; | ||
| 97 | try { | 21 | try { |
| 98 | const hostname = new URL(url).hostname; | 22 | const hostname = new URL(url).hostname; |
| 99 | const sourceUrl = new URL(url); | 23 | const parsed = parseDomain(hostname); |
| 100 | const hostCandidates = [ | 24 | const domain = parsed.domain ?? hostname; |
| 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 | } | ||
| 112 | 25 | ||
| 113 | return uniqueFaviconUrls(candidateUrls); | 26 | return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`; |
| 114 | } catch { | 27 | } catch { |
| 115 | return []; | 28 | return null; |
| 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; | ||
| 131 | } | 29 | } |
| 132 | |||
| 133 | return `/api/favicon?url=${encodeURIComponent(trimmed)}`; | ||
| 134 | } | 30 | } |
| 135 | 31 | ||
| 136 | function sourceFaviconSourceUrl(source: ToolSourceRecord): string | null { | 32 | function sourceFaviconSourceUrl(source: ToolSourceRecord): string | null { |
| @@ -164,11 +60,6 @@ function sourceFaviconSourceUrl(source: ToolSourceRecord): string | null { · 27 unmodified lines | |||
| 164 | ?? parseOrigin(spec); | 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 | export function getSourceFavicon(source: ToolSourceRecord): string | null { | 63 | export function getSourceFavicon(source: ToolSourceRecord): string | null { |
| 173 | const sourceUrl = sourceFaviconSourceUrl(source); | 64 | const sourceUrl = sourceFaviconSourceUrl(source); |
| 174 | return sourceUrl ? getSourceFaviconUrl(sourceUrl) : null; | 65 | return sourceUrl ? getSourceFaviconUrl(sourceUrl) : null; |
| @@ -28,6 +28,7 @@ · 27 unmodified lines | |||
| 28 | "codegen": "bunx convex codegen", | 28 | "codegen": "bunx convex codegen", |
| 29 | "test:e2e:install": "bun test scripts/e2e/install.e2e.test.ts", | 29 | "test:e2e:install": "bun test scripts/e2e/install.e2e.test.ts", |
| 30 | "test:e2e:install:sandbox": "bun test scripts/e2e/install.sandbox.e2e.test.ts", | 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 | "build:binary": "bun build executor.ts --compile --outfile dist/executor", | 32 | "build:binary": "bun build executor.ts --compile --outfile dist/executor", |
| 32 | "build:release": "bun run scripts/release/build-release.ts" | 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 | "type": "remote", | 5 | "type": "remote", |
| 6 | "url": "https://www.answeroverflow.com/mcp", | 6 | "url": "https://www.answeroverflow.com/mcp", |
| 7 | "enabled": true | 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 | "agent":{ | 15 | "agent":{ |