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
178 178
179```bash179```bash
180bun test180bun test
181bun 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
190Useful 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
183From repo root:196From repo root:
184 197
185```bash198```bash
executor/apps/web/src/app/api/favicon/route.ts+0−102
@@ -1,102 +0,0 @@
1import type { NextRequest } from "next/server";
2import { parse as parseDomain } from "tldts";
3
4const MAX_REDIRECTS = 4;
5
6function 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
33async 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
67export 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 @@
1"use client";1"use client";
2 2
3import { useEffect, useMemo, useState } from "react";3import { useEffect, useState } from "react";
4import { Layers, Globe, Server } from "lucide-react";4import { Layers, Globe, Server } from "lucide-react";
5import Image from "next/image";
5import type { ToolSourceRecord } from "@/lib/types";6import type { ToolSourceRecord } from "@/lib/types";
6import {7import { getSourceFavicon, getSourceFaviconUrl } from "@/lib/tools/source-helpers";
7 getSourceFaviconCandidates,
8 getSourceFaviconCandidatesForSource,
9 getSourceFaviconProxyUrl,
10} from "@/lib/tools/source-helpers";
11 8
12interface SourceFaviconProps {9interface 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 source41 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 <img53 <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}
executor/apps/web/src/lib/tools/source-helpers.test.ts+0−47
@@ -1,47 +0,0 @@
1import { describe, expect, test } from "bun:test";
2import { getSourceFaviconCandidates, getSourceFaviconProxyUrl } from "./source-helpers";
3
4describe("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
16 "raw.github.com",16 "raw.github.com",
17]);17]);
18 18
19function normalizeFaviconDomain(value: string): string {19export function getSourceFaviconUrl(url: string | undefined | null): string | null {
20 return (value || "").toLowerCase().trim();20 if (!url) return null;
21}
22
23function 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
39function 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
61function 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
95export 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
119export function getSourceFaviconUrl(url: string | undefined | null): string | null {
120 return getSourceFaviconCandidates(url)[0] ?? null;
121}
122
123export 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
136function sourceFaviconSourceUrl(source: ToolSourceRecord): string | null {32function 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
167export function getSourceFaviconCandidatesForSource(source: ToolSourceRecord): string[] {
168 const sourceUrl = sourceFaviconSourceUrl(source);
169 return getSourceFaviconCandidates(sourceUrl);
170}
171
172export function getSourceFavicon(source: ToolSourceRecord): string | null {63export 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;
executor/package.json+1−0
@@ -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 },
executor/scripts/e2e/install.sandbox.session.ts+155−0
@@ -0,0 +1,155 @@
1import { Sandbox } from "@vercel/sandbox";
2
3type CommandResult = {
4 exitCode: number;
5 stdout: string;
6 stderr: string;
7};
8
9function 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
23function 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
35async 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
67function 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
81const backendPort = parseIntegerEnv("EXECUTOR_BACKEND_PORT", 5410);
82const sitePort = parseIntegerEnv("EXECUTOR_BACKEND_SITE_PORT", 5411);
83const webPort = parseIntegerEnv("EXECUTOR_WEB_PORT", 5312);
84const sandboxTimeoutMs = parseIntegerEnv("EXECUTOR_SANDBOX_TIMEOUT_MS", 30 * 60 * 1000);
85const installTimeoutMs = parseIntegerEnv("EXECUTOR_SANDBOX_INSTALL_TIMEOUT_MS", 15 * 60 * 1000);
86
87let sandbox: Sandbox | null = null;
88
89try {
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
103try {
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
5 "type": "remote",5 "type": "remote",
6 "url": "https://www.answeroverflow.com/mcp",6 "url": "https://www.answeroverflow.com/mcp",
7 "enabled": true7 "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":{