History

Add local Oracle configuration to opencode.json, introduce sandbox installation script in package.json, and update README with sandbox usage details. Remove unused favicon route and refactor favicon retrieval logic in source helpers.

Rhys Sullivan committed 8 months ago

Commit f5a9bee1 parent 1f4a3248 files · +196 −309Browse files

Unified · Split

8 files
  1. executor/README.md +13 −0
  2. executor/apps/web/src/app/api/favicon/route.ts +0 −102
  3. executor/apps/web/src/components/tools/source-favicon.tsx +16 −45
  4. executor/apps/web/src/lib/tools/source-helpers.test.ts +0 −47
  5. executor/apps/web/src/lib/tools/source-helpers.ts +6 −115
  6. executor/package.json +1 −0
  7. executor/scripts/e2e/install.sandbox.session.ts +155 −0
  8. opencode.json +5 −0
executor/README.md+13−0
@@ -178,8 +178,21 @@ From `executor/`: · 177 unmodified lines
178178
179179 ```bash
180180 bun test
181+bun run sandbox:install:session
181182 ```
182183
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+
183196 From repo root:
184197
185198 ```bash
executor/apps/web/src/app/api/favicon/route.ts+0−102
@@ -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−}
executor/apps/web/src/components/tools/source-favicon.tsx+16−45
@@ -1,13 +1,10 @@
11 "use client";
22
3−import { useEffect, useMemo, useState } from "react";
3+import { useEffect, useState } from "react";
44 import { Layers, Globe, Server } from "lucide-react";
5+import Image from "next/image";
56 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";
118
129 interface SourceFaviconProps {
1310 source?: ToolSourceRecord;
@@ -36,58 +33,32 @@ export function SourceFavicon({ · 22 unmodified lines
3633 imageSize = 20,
3734 fallbackType,
3835 }: 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);
6242
6343 useEffect(() => {
64− setFaviconIndex(0);
65− }, [sourceFaviconCandidateKey]);
44+ setFailed(false);
45+ }, [sourceFavicon]);
6646
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) {
7248 const sourceType = fallbackType ?? source?.type ?? "openapi";
7349 return <DefaultSourceIcon type={sourceType} className={iconClassName} />;
7450 }
7551
76− const handleFaviconError = () => {
77− setFaviconIndex((current) => {
78− return current + 1;
79− });
80− };
81−
8252 return (
83− <img
84− src={sourceFaviconSrc}
53+ <Image
54+ src={sourceFavicon}
8555 alt=""
8656 width={imageSize}
8757 height={imageSize}
8858 className={imageClassName ?? "w-full h-full object-contain"}
8959 loading="lazy"
90− onError={handleFaviconError}
60+ unoptimized
61+ onError={() => setFailed(true)}
9162 />
9263 );
9364 }
executor/apps/web/src/lib/tools/source-helpers.test.ts+0−47
@@ -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−});
executor/apps/web/src/lib/tools/source-helpers.ts+6−115
@@ -16,121 +16,17 @@ const RAW_HOSTS = new Set([ · 15 unmodified lines
1616 "raw.github.com",
1717 ]);
1818
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;
9721 try {
9822 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;
11225
113− return uniqueFaviconUrls(candidateUrls);
26+ return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`;
11427 } 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;
13129 }
132−
133− return `/api/favicon?url=${encodeURIComponent(trimmed)}`;
13430 }
13531
13632 function sourceFaviconSourceUrl(source: ToolSourceRecord): string | null {
@@ -164,11 +60,6 @@ function sourceFaviconSourceUrl(source: ToolSourceRecord): string | null { · 27 unmodified lines
16460 ?? parseOrigin(spec);
16561 }
16662
167−export function getSourceFaviconCandidatesForSource(source: ToolSourceRecord): string[] {
168− const sourceUrl = sourceFaviconSourceUrl(source);
169− return getSourceFaviconCandidates(sourceUrl);
170−}
171−
17263 export function getSourceFavicon(source: ToolSourceRecord): string | null {
17364 const sourceUrl = sourceFaviconSourceUrl(source);
17465 return sourceUrl ? getSourceFaviconUrl(sourceUrl) : null;
executor/package.json+1−0
@@ -28,6 +28,7 @@ · 27 unmodified lines
2828 "codegen": "bunx convex codegen",
2929 "test:e2e:install": "bun test scripts/e2e/install.e2e.test.ts",
3030 "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",
3132 "build:binary": "bun build executor.ts --compile --outfile dist/executor",
3233 "build:release": "bun run scripts/release/build-release.ts"
3334 },
executor/scripts/e2e/install.sandbox.session.ts+155−0
@@ -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+}
opencode.json+5−0
@@ -5,6 +5,11 @@ · 4 unmodified lines
55 "type": "remote",
66 "url": "https://www.answeroverflow.com/mcp",
77 "enabled": true
8+ },
9+ "oracle": {
10+ "type": "local",
11+ "command": ["npx", "-y", "@steipete/oracle", "oracle-mcp"],
12+ "enabled": true
813 }
914 },
1015 "agent":{