From 63ed4725c3327d9c825c7350f9a6e6d896731151 Mon Sep 17 00:00:00 2001 From: Andrew Joslin Date: Fri, 16 Jan 2026 11:04:28 -0800 Subject: [PATCH 1/3] feat: add manual server registry with proxy and auth --- .gitignore | 1 + .../api/opencode/[port]/[[...path]]/route.ts | 30 ++- .../web/src/app/api/opencode/servers/route.ts | 209 ++++++++++----- apps/web/src/app/api/sse/[port]/route.ts | 27 +- .../src/lib/manual-server-registry.test.ts | 221 ++++++++++++++++ apps/web/src/lib/manual-server-registry.ts | 249 ++++++++++++++++++ bun.lock | 8 + packages/core/src/atoms/servers.test.ts | 6 +- 8 files changed, 662 insertions(+), 89 deletions(-) create mode 100644 apps/web/src/lib/manual-server-registry.test.ts create mode 100644 apps/web/src/lib/manual-server-registry.ts diff --git a/.gitignore b/.gitignore index b622b4e..21c79e9 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ bun.lockb # npm auth (created by CI) .npmrc .swarm/ +opensrc/ diff --git a/apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts b/apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts index f5654e2..653435b 100644 --- a/apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts +++ b/apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server" +import { type NextRequest, NextResponse } from "next/server" /** * API Proxy for OpenCode servers @@ -31,6 +31,8 @@ import { NextRequest, NextResponse } from "next/server" * // Returns response to browser (same-origin) */ +import { createAuthorizationHeader, getManualServerByProxyPort } from "@/lib/manual-server-registry" + type RouteContext = { params: Promise<{ port: string @@ -38,10 +40,6 @@ type RouteContext = { }> } -/** - * Reserved segments that should not be treated as port numbers - * These have their own static route handlers (e.g., /api/opencode/servers/route.ts) - */ const RESERVED_SEGMENTS = new Set(["servers"]) /** @@ -82,9 +80,13 @@ function validatePort( * buildTargetUrl(4056, ['session', 'list']) * // => 'http://127.0.0.1:4056/session/list' */ -function buildTargetUrl(port: number, path: string[] = []): string { +function buildTargetUrl(base: string | number, path: string[] = []): string { const pathString = path.length > 0 ? `/${path.join("/")}` : "" - return `http://127.0.0.1:${port}${pathString}` + const baseString = String(base) + if (baseString.startsWith("http")) { + return `${baseString}${pathString}` + } + return `http://127.0.0.1:${baseString}${pathString}` } /** @@ -100,7 +102,10 @@ async function proxyRequest( port: number, path: string[] = [], ): Promise { - const targetUrl = buildTargetUrl(port, path) + const manualServer = await getManualServerByProxyPort(port) + const targetUrl = manualServer + ? buildTargetUrl(manualServer.url, path) + : buildTargetUrl(port, path) try { // Copy headers from incoming request @@ -112,13 +117,18 @@ async function proxyRequest( headers.set("x-opencode-directory", directoryHeader) } - // Preserve content-type for POST/PUT/PATCH const contentType = request.headers.get("content-type") if (contentType) { headers.set("content-type", contentType) } - // Copy body for POST/PUT/PATCH + if (manualServer) { + const authorization = createAuthorizationHeader(manualServer) + if (authorization) { + headers.set("authorization", authorization) + } + } + let body: ReadableStream | null = null if (["POST", "PUT", "PATCH"].includes(request.method)) { body = request.body diff --git a/apps/web/src/app/api/opencode/servers/route.ts b/apps/web/src/app/api/opencode/servers/route.ts index b5f17a3..3020333 100644 --- a/apps/web/src/app/api/opencode/servers/route.ts +++ b/apps/web/src/app/api/opencode/servers/route.ts @@ -1,24 +1,13 @@ -/** - * Server Discovery API Route - * - * Discovers running opencode servers by scanning processes. - * Uses lsof to find processes listening on ports with "bun" or "opencode" in the command. - * Verifies each candidate by hitting /project endpoint and captures the directory. - * - * Returns: Array<{ port: number; pid: number; directory: string }> - * - * This enables routing messages to the correct server based on directory! - * - * Performance optimizations: - * - Parallel verification of all candidate ports - * - 2s timeout on lsof command - * - 300ms timeout on each verification request - * - Results cached for 2s via Cache-Control header - */ - import { exec } from "child_process" -import { promisify } from "util" import { NextResponse } from "next/server" +import { promisify } from "util" +import { + addServer, + type ManualServer, + readRegistry, + removeServer, + verifyManualServer, +} from "@/lib/manual-server-registry" const execAsync = promisify(exec) @@ -26,7 +15,11 @@ interface DiscoveredServer { port: number pid: number directory: string - sessions?: string[] // Session IDs hosted by this server + sessions?: string[] + source: "local" | "manual" + url?: string + name?: string + proxyPort?: number } interface CandidatePort { @@ -34,16 +27,11 @@ interface CandidatePort { pid: number } -/** - * Verify a port is actually an opencode server and get its directory + sessions - * Returns null if not a valid opencode server - */ async function verifyOpencodeServer(candidate: CandidatePort): Promise { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 500) try { - // Fetch project info const res = await fetch(`http://127.0.0.1:${candidate.port}/project/current`, { signal: controller.signal, }) @@ -58,7 +46,6 @@ async function verifyOpencodeServer(candidate: CandidatePort): Promise controller.abort(), 300) @@ -74,7 +61,6 @@ async function verifyOpencodeServer(candidate: CandidatePort): Promise { + const results = await Promise.all( + manualServers.map(async (server) => { + const verified = await verifyManualServer(server, 2000) + if (!verified) return null + + const resolvedServer: DiscoveredServer = { + port: verified.proxyPort, + pid: 0, + directory: verified.directory, + sessions: verified.sessions, + source: "manual" as const, + url: server.url, + name: server.name, + proxyPort: verified.proxyPort, + } + return resolvedServer + }), + ) + + return results.filter((s) => s !== null) +} + async function promiseAllSettledLimit(tasks: (() => Promise)[], limit: number): Promise { const results: T[] = [] let index = 0 @@ -105,7 +114,7 @@ async function promiseAllSettledLimit(tasks: (() => Promise)[], limit: num try { results[currentIndex] = await task() } catch { - // Swallow errors, results[currentIndex] stays undefined + // intentional no-op } } } @@ -119,46 +128,16 @@ export async function GET() { const startTime = Date.now() try { - // Find all listening TCP ports for bun/opencode processes - const { stdout } = await execAsync( - `lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | grep -E 'bun|opencode' | awk '{print $2, $9}'`, - { timeout: 2000 }, - ).catch((error) => { - // lsof returns exit code 1 when grep finds no matches - that's OK - if (error.stdout !== undefined) { - return { stdout: error.stdout || "" } - } - console.error("[opencode/servers] lsof failed:", error.message) - throw error - }) - - // Parse candidates - const candidates: CandidatePort[] = [] - const seen = new Set() - - for (const line of stdout.trim().split("\n")) { - if (!line) continue - const [pid, address] = line.split(" ") - const portMatch = address?.match(/:(\d+)$/) - if (!portMatch) continue - - const port = parseInt(portMatch[1], 10) - if (seen.has(port)) continue - seen.add(port) - - candidates.push({ port, pid: parseInt(pid, 10) }) - } + const [localServers, manualServers] = await Promise.all([ + discoverLocalServers(), + discoverManualServers(), + ]) - // Verify candidates with limited concurrency (max 5 at a time) - const tasks = candidates.map((c) => () => verifyOpencodeServer(c)) - const results = await promiseAllSettledLimit(tasks, 5) - const servers = results.filter((s): s is DiscoveredServer => s !== null) + const servers = [...localServers, ...manualServers] const duration = Date.now() - startTime if (duration > 500) { - console.warn( - `[opencode/servers] Slow discovery: ${duration}ms for ${candidates.length} candidates`, - ) + console.warn(`[opencode/servers] Slow discovery: ${duration}ms`) } return NextResponse.json(servers, { @@ -174,7 +153,6 @@ export async function GET() { duration: `${duration}ms`, }) - // Return 500 with error details in dev return NextResponse.json( { error: "Server discovery failed", @@ -185,3 +163,98 @@ export async function GET() { ) } } + +async function discoverLocalServers(): Promise { + const { stdout } = await execAsync( + `lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | grep -E 'bun|opencode' | awk '{print $2, $9}'`, + { timeout: 2000 }, + ).catch((error) => { + if (error.stdout !== undefined) { + return { stdout: error.stdout || "" } + } + console.error("[opencode/servers] lsof failed:", error.message) + throw error + }) + + const candidates: CandidatePort[] = [] + const seen = new Set() + + for (const line of stdout.trim().split("\n")) { + if (!line) continue + const [pid, address] = line.split(" ") + const portMatch = address?.match(/:(\d+)$/) + if (!portMatch) continue + + const port = parseInt(portMatch[1], 10) + if (seen.has(port)) continue + seen.add(port) + + candidates.push({ port, pid: parseInt(pid, 10) }) + } + + const tasks = candidates.map((c) => () => verifyOpencodeServer(c)) + const results = await promiseAllSettledLimit(tasks, 5) + return results.filter((s): s is DiscoveredServer => s !== null) +} + +async function discoverManualServers(): Promise { + const manualServers = await readRegistry() + return verifyAndTransformManualServers(manualServers) +} + +export async function POST(request: Request) { + try { + const body = await request.json() + const { url, name } = body as { url?: string; name?: string } + + if (!url) { + return NextResponse.json({ error: "url is required" }, { status: 400 }) + } + + const server = await addServer(url, name) + + return NextResponse.json( + { + url: server.url, + name: server.name, + proxyPort: server.proxyPort, + addedAt: server.addedAt, + }, + { status: 201 }, + ) + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error" + + if (message.includes("already registered") || message.includes("Invalid URL")) { + return NextResponse.json({ error: message }, { status: 400 }) + } + + console.error("[opencode/servers] POST failed:", error) + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +export async function DELETE(request: Request) { + try { + const { searchParams } = new URL(request.url) + const url = searchParams.get("url") + + if (!url) { + return NextResponse.json({ error: "url query param is required" }, { status: 400 }) + } + + const removed = await removeServer(url) + + if (!removed) { + return NextResponse.json({ error: "Server not found" }, { status: 404 }) + } + + return new NextResponse(null, { status: 204 }) + } catch (error) { + console.error("[opencode/servers] DELETE failed:", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 }, + ) + } +} diff --git a/apps/web/src/app/api/sse/[port]/route.ts b/apps/web/src/app/api/sse/[port]/route.ts index ac7123c..150e6ec 100644 --- a/apps/web/src/app/api/sse/[port]/route.ts +++ b/apps/web/src/app/api/sse/[port]/route.ts @@ -1,4 +1,5 @@ -import { NextRequest, NextResponse } from "next/server" +import { type NextRequest, NextResponse } from "next/server" +import { createAuthorizationHeader, getManualServerByProxyPort } from "@/lib/manual-server-registry" export async function GET(request: NextRequest, { params }: { params: Promise<{ port: string }> }) { const { port } = await params // Next.js 16 requires await @@ -10,18 +11,28 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const portNum = parseInt(port, 10) - // Validate port is in reasonable range if (portNum < 1024 || portNum > 65535) { return NextResponse.json({ error: "Port out of valid range" }, { status: 400 }) } + const manualServer = await getManualServerByProxyPort(portNum) + const targetUrl = manualServer + ? `${manualServer.url}/global/event` + : `http://127.0.0.1:${portNum}/global/event` + + const headers = new Headers({ + Accept: "text/event-stream", + "Cache-Control": "no-cache", + }) + if (manualServer) { + const authorization = createAuthorizationHeader(manualServer) + if (authorization) { + headers.set("authorization", authorization) + } + } + try { - const response = await fetch(`http://127.0.0.1:${portNum}/global/event`, { - headers: { - Accept: "text/event-stream", - "Cache-Control": "no-cache", - }, - }) + const response = await fetch(targetUrl, { headers }) if (!response.ok) { return NextResponse.json( diff --git a/apps/web/src/lib/manual-server-registry.test.ts b/apps/web/src/lib/manual-server-registry.test.ts new file mode 100644 index 0000000..b6459a3 --- /dev/null +++ b/apps/web/src/lib/manual-server-registry.test.ts @@ -0,0 +1,221 @@ +import { mkdir, readFile, rm, writeFile } from "fs/promises" +import { tmpdir } from "os" +import { join } from "path" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +const TEST_DIR = join(tmpdir(), `manual-server-registry-test-${process.pid}`) + +vi.mock("os", async () => { + const actual = await vi.importActual("os") + return { + ...actual, + homedir: () => TEST_DIR, + } +}) + +import { + addServer, + type ManualServer, + readRegistry, + removeServer, + verifyManualServer, +} from "./manual-server-registry" + +const STATE_DIR = join(TEST_DIR, ".local", "state", "opencode") +const REGISTRY_PATH = join(STATE_DIR, "manual-servers.json") + +describe("manual-server-registry", () => { + beforeEach(async () => { + await mkdir(STATE_DIR, { recursive: true }) + }) + + afterEach(async () => { + await rm(TEST_DIR, { recursive: true, force: true }) + }) + + describe("readRegistry", () => { + test("returns empty array when file does not exist", async () => { + const servers = await readRegistry() + expect(servers).toEqual([]) + }) + + test("returns servers from valid file", async () => { + const data = { + servers: [{ url: "http://sandbox:4056", name: "test", addedAt: 1000, proxyPort: 50001 }], + } + await writeFile(REGISTRY_PATH, JSON.stringify(data)) + + const servers = await readRegistry() + expect(servers).toHaveLength(1) + expect(servers[0]!.url).toBe("http://sandbox:4056") + expect(servers[0]!.proxyPort).toBe(50001) + }) + + test("returns empty array for invalid JSON", async () => { + await writeFile(REGISTRY_PATH, "not json") + + const servers = await readRegistry() + expect(servers).toEqual([]) + }) + + test("assigns proxy ports for legacy entries", async () => { + const data = { + servers: [{ url: "http://sandbox:4056", name: "legacy", addedAt: 1000 }], + } + await writeFile(REGISTRY_PATH, JSON.stringify(data)) + + const servers = await readRegistry() + expect(servers).toHaveLength(1) + expect(servers[0]!.proxyPort).toBeGreaterThanOrEqual(49152) + expect(servers[0]!.proxyPort).toBeLessThanOrEqual(65535) + }) + + test("returns empty array if servers is not an array", async () => { + await writeFile(REGISTRY_PATH, JSON.stringify({ servers: "not an array" })) + + const servers = await readRegistry() + expect(servers).toEqual([]) + }) + }) + + describe("addServer", () => { + test("adds server to empty registry", async () => { + const server = await addServer("http://sandbox:4056", "my-sandbox") + + expect(server.url).toBe("http://sandbox:4056") + expect(server.name).toBe("my-sandbox") + expect(server.addedAt).toBeGreaterThan(0) + expect(server.proxyPort).toBeGreaterThanOrEqual(49152) + expect(server.proxyPort).toBeLessThanOrEqual(65535) + + const content = await readFile(REGISTRY_PATH, "utf-8") + const data = JSON.parse(content) + expect(data.servers).toHaveLength(1) + }) + + test("extracts credentials from URL", async () => { + const server = await addServer("http://myuser:secret123@sandbox:4056") + + expect(server.url).toBe("http://sandbox:4056") + expect(server.username).toBe("myuser") + expect(server.password).toBe("secret123") + expect(server.proxyPort).toBeGreaterThanOrEqual(49152) + expect(server.proxyPort).toBeLessThanOrEqual(65535) + }) + + test("extracts password-only credentials (uses default username)", async () => { + const server = await addServer("http://:secret123@sandbox:4056") + + expect(server.url).toBe("http://sandbox:4056") + expect(server.username).toBeUndefined() + expect(server.password).toBe("secret123") + expect(server.proxyPort).toBeGreaterThanOrEqual(49152) + expect(server.proxyPort).toBeLessThanOrEqual(65535) + }) + + test("adds protocol if missing", async () => { + const server = await addServer("sandbox:4056") + + expect(server.url).toBe("http://sandbox:4056") + }) + + test("removes trailing slashes", async () => { + const server = await addServer("http://sandbox:4056///") + + expect(server.url).toBe("http://sandbox:4056") + }) + + test("throws on invalid URL", async () => { + await expect(addServer(":::invalid")).rejects.toThrow() + }) + + test("throws on duplicate URL", async () => { + await addServer("http://sandbox:4056") + + await expect(addServer("http://sandbox:4056")).rejects.toThrow("already registered") + }) + + test("treats URLs with different credentials as same server", async () => { + await addServer("http://user1:pass1@sandbox:4056") + + await expect(addServer("http://user2:pass2@sandbox:4056")).rejects.toThrow( + "already registered", + ) + }) + + test("returns stable proxy port for same URL", async () => { + const first = await addServer("http://sandbox:4056") + await removeServer("http://sandbox:4056") + const second = await addServer("http://sandbox:4056") + + expect(first.proxyPort).toBe(second.proxyPort) + }) + + test("trims whitespace from name", async () => { + const server = await addServer("http://sandbox:4056", " my sandbox ") + + expect(server.name).toBe("my sandbox") + }) + + test("omits name if empty string", async () => { + const server = await addServer("http://sandbox:4056", "") + + expect(server.name).toBeUndefined() + }) + }) + + describe("removeServer", () => { + test("removes existing server", async () => { + await addServer("http://sandbox:4056") + + const removed = await removeServer("http://sandbox:4056") + + expect(removed).toBe(true) + const servers = await readRegistry() + expect(servers).toHaveLength(0) + }) + + test("returns false for non-existent server", async () => { + const removed = await removeServer("http://nonexistent:4056") + + expect(removed).toBe(false) + }) + + test("removes server by URL with credentials stripped", async () => { + await addServer("http://user:pass@sandbox:4056") + + const removed = await removeServer("http://sandbox:4056") + + expect(removed).toBe(true) + }) + }) + + describe("verifyManualServer", () => { + test("returns null on network error", async () => { + const server: ManualServer = { + url: "http://localhost:59999", + proxyPort: 50010, + addedAt: Date.now(), + } + + const result = await verifyManualServer(server, 100) + + expect(result).toBeNull() + }) + + test("returns null on timeout", async () => { + const server: ManualServer = { + url: "http://10.255.255.1:4056", + proxyPort: 50011, + addedAt: Date.now(), + } + + const start = Date.now() + const result = await verifyManualServer(server, 100) + const elapsed = Date.now() - start + + expect(result).toBeNull() + expect(elapsed).toBeLessThan(500) + }) + }) +}) diff --git a/apps/web/src/lib/manual-server-registry.ts b/apps/web/src/lib/manual-server-registry.ts new file mode 100644 index 0000000..95fba3e --- /dev/null +++ b/apps/web/src/lib/manual-server-registry.ts @@ -0,0 +1,249 @@ +/** + * Manual Server Registry + * + * Persists manually-registered remote opencode servers to ~/.local/state/opencode/manual-servers.json + * These are servers on other machines/sandboxes not discoverable via lsof. + */ + +import { mkdir, readFile, writeFile } from "fs/promises" +import { homedir } from "os" +import { join } from "path" + +export interface ManualServer { + url: string + name?: string + username?: string + password?: string + proxyPort: number + addedAt: number +} + +interface RegistryFile { + servers: ManualServer[] +} + +const PROXY_PORT_MIN = 49152 +const PROXY_PORT_MAX = 65535 +const PROXY_PORT_RANGE = PROXY_PORT_MAX - PROXY_PORT_MIN + 1 + +function getStateDir(): string { + return join(homedir(), ".local", "state", "opencode") +} + +function getRegistryPath(): string { + return join(getStateDir(), "manual-servers.json") +} + +function makeBasicAuthHeader(username: string, password: string): string { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +export function parseUrlWithCredentials(rawUrl: string): { + url: string + username?: string + password?: string +} { + let normalized = rawUrl.trim() + if (!normalized.match(/^https?:\/\//)) { + normalized = `http://${normalized}` + } + normalized = normalized.replace(/\/+$/, "") + + let parsed: URL + try { + parsed = new URL(normalized) + } catch { + throw new Error(`Invalid URL: ${rawUrl}`) + } + + const username = parsed.username || undefined + const password = parsed.password || undefined + + parsed.username = "" + parsed.password = "" + const cleanUrl = parsed.toString().replace(/\/+$/, "") + + return { url: cleanUrl, username, password } +} + +function hashString(value: string): number { + let hash = 0 + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 31 + value.charCodeAt(i)) >>> 0 + } + return hash +} + +function computeProxyPort(url: string, usedPorts: Set): number { + const baseHash = hashString(url) + for (let attempt = 0; attempt < PROXY_PORT_RANGE; attempt += 1) { + const hash = baseHash + attempt + const port = PROXY_PORT_MIN + (hash % PROXY_PORT_RANGE) + if (!usedPorts.has(port)) { + return port + } + } + + throw new Error("No available proxy ports") +} + +function normalizeServers(servers: ManualServer[]): { servers: ManualServer[]; changed: boolean } { + const usedPorts = new Set(servers.map((server) => server.proxyPort).filter(Boolean)) + let changed = false + + const normalized = servers.map((server) => { + if (server.proxyPort) return server + const proxyPort = computeProxyPort(server.url, usedPorts) + usedPorts.add(proxyPort) + changed = true + return { ...server, proxyPort } + }) + + return { servers: normalized, changed } +} + +export function createAuthorizationHeader(server: ManualServer): string | undefined { + if (!server.password) return undefined + const username = server.username || "opencode" + return makeBasicAuthHeader(username, server.password) +} + +export async function getManualServerByProxyPort(port: number): Promise { + const servers = await readRegistry() + return servers.find((server) => server.proxyPort === port) +} + +export async function readRegistry(): Promise { + try { + const content = await readFile(getRegistryPath(), "utf-8") + const data: RegistryFile = JSON.parse(content) + if (!Array.isArray(data.servers)) return [] + const normalized = normalizeServers(data.servers) + if (normalized.changed) { + await writeRegistry(normalized.servers) + } + return normalized.servers + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return [] + } + console.error("[manual-registry] Failed to read registry:", error) + return [] + } +} + +async function writeRegistry(servers: ManualServer[]): Promise { + const stateDir = getStateDir() + await mkdir(stateDir, { recursive: true }) + const data: RegistryFile = { servers } + await writeFile(getRegistryPath(), JSON.stringify(data, null, 2), "utf-8") +} + +export async function addServer(rawUrl: string, name?: string): Promise { + const { url, username, password } = parseUrlWithCredentials(rawUrl) + + const servers = await readRegistry() + + if (servers.some((s) => s.url === url)) { + throw new Error(`Server already registered: ${url}`) + } + + const usedPorts = new Set(servers.map((server) => server.proxyPort)) + const proxyPort = computeProxyPort(url, usedPorts) + const newServer: ManualServer = { + url, + name: name?.trim() || undefined, + username, + password, + proxyPort, + addedAt: Date.now(), + } + + servers.push(newServer) + await writeRegistry(servers) + + return newServer +} + +export async function removeServer(rawUrl: string): Promise { + const { url } = parseUrlWithCredentials(rawUrl) + const servers = await readRegistry() + + const index = servers.findIndex((s) => s.url === url) + if (index === -1) { + return false + } + + servers.splice(index, 1) + await writeRegistry(servers) + + return true +} + +function getAuthHeaders(server: ManualServer): HeadersInit { + const authorization = createAuthorizationHeader(server) + return authorization ? { Authorization: authorization } : {} +} + +export async function verifyManualServer( + server: ManualServer, + timeoutMs = 2000, +): Promise<{ + url: string + name?: string + directory: string + sessions?: string[] + proxyPort: number +} | null> { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeoutMs) + const headers = getAuthHeaders(server) + + try { + const res = await fetch(`${server.url}/project/current`, { + signal: controller.signal, + headers, + }) + clearTimeout(timeoutId) + + if (!res.ok) return null + + const project = await res.json() + const directory = project.worktree + + if (!directory || directory === "/" || directory.length <= 1) { + return null + } + + let sessions: string[] | undefined + try { + const sessionController = new AbortController() + const sessionTimeoutId = setTimeout(() => sessionController.abort(), 1000) + const sessionRes = await fetch(`${server.url}/session`, { + signal: sessionController.signal, + headers, + }) + clearTimeout(sessionTimeoutId) + + if (sessionRes.ok) { + const sessionList = await sessionRes.json() + sessions = Array.isArray(sessionList) + ? sessionList.map((s: { id: string }) => s.id) + : undefined + } + } catch { + sessions = undefined + } + + return { + url: server.url, + name: server.name, + directory, + sessions, + proxyPort: server.proxyPort, + } + } catch { + clearTimeout(timeoutId) + return null + } +} diff --git a/bun.lock b/bun.lock index 83ab26d..18873df 100644 --- a/bun.lock +++ b/bun.lock @@ -1875,6 +1875,10 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@opencode-vibe/core/@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + + "@opencode-vibe/swarm-cli/@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -1977,6 +1981,10 @@ "@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@opencode-vibe/core/@types/bun/bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + + "@opencode-vibe/swarm-cli/@types/bun/bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "@tailwindcss/postcss/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], diff --git a/packages/core/src/atoms/servers.test.ts b/packages/core/src/atoms/servers.test.ts index 6d5c0c9..10a057b 100644 --- a/packages/core/src/atoms/servers.test.ts +++ b/packages/core/src/atoms/servers.test.ts @@ -5,10 +5,10 @@ * No React dependencies - tests Effect logic directly. */ -import { describe, expect, it } from "vitest" import { Effect } from "effect" -import { ServerAtom, selectBestServer, DEFAULT_SERVER } from "./servers.js" -import type { ServerInfo } from "@opencode-vibe/core/discovery" +import { describe, expect, it } from "vitest" +import type { ServerInfo } from "../discovery/index.js" +import { DEFAULT_SERVER, ServerAtom, selectBestServer } from "./servers.js" describe("selectBestServer", () => { it("returns first server with directory when available", () => { From 7fcd04fb54c286bb20c18221dd14f1f9de440809 Mon Sep 17 00:00:00 2001 From: Andrew Joslin Date: Fri, 16 Jan 2026 11:12:22 -0800 Subject: [PATCH 2/3] fix missing comments --- .../api/opencode/[port]/[[...path]]/route.ts | 7 ++++ .../web/src/app/api/opencode/servers/route.ts | 32 +++++++++++++++++-- apps/web/src/app/api/sse/[port]/route.ts | 2 ++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts b/apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts index 653435b..6d93093 100644 --- a/apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts +++ b/apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts @@ -40,6 +40,10 @@ type RouteContext = { }> } +/** + * Reserved segments that should not be treated as port numbers + * These have their own static route handlers (e.g., /api/opencode/servers/route.ts) + */ const RESERVED_SEGMENTS = new Set(["servers"]) /** @@ -117,11 +121,13 @@ async function proxyRequest( headers.set("x-opencode-directory", directoryHeader) } + // Preserve content-type for POST/PUT/PATCH const contentType = request.headers.get("content-type") if (contentType) { headers.set("content-type", contentType) } + // Add auth for manual (remote) servers if (manualServer) { const authorization = createAuthorizationHeader(manualServer) if (authorization) { @@ -129,6 +135,7 @@ async function proxyRequest( } } + // Copy body for POST/PUT/PATCH let body: ReadableStream | null = null if (["POST", "PUT", "PATCH"].includes(request.method)) { body = request.body diff --git a/apps/web/src/app/api/opencode/servers/route.ts b/apps/web/src/app/api/opencode/servers/route.ts index 3020333..7ffec1f 100644 --- a/apps/web/src/app/api/opencode/servers/route.ts +++ b/apps/web/src/app/api/opencode/servers/route.ts @@ -1,3 +1,21 @@ +/** + * Server Discovery API Route + * + * Discovers running opencode servers by scanning processes. + * Uses lsof to find processes listening on ports with "bun" or "opencode" in the command. + * Verifies each candidate by hitting /project endpoint and captures the directory. + * + * Returns: Array<{ port: number; pid: number; directory: string }> + * + * This enables routing messages to the correct server based on directory! + * + * Performance optimizations: + * - Parallel verification of all candidate ports + * - 2s timeout on lsof command + * - 300ms timeout on each verification request + * - Results cached for 2s via Cache-Control header + */ + import { exec } from "child_process" import { NextResponse } from "next/server" import { promisify } from "util" @@ -15,7 +33,7 @@ interface DiscoveredServer { port: number pid: number directory: string - sessions?: string[] + sessions?: string[] // Session IDs hosted by this server source: "local" | "manual" url?: string name?: string @@ -27,11 +45,16 @@ interface CandidatePort { pid: number } +/** + * Verify a port is actually an opencode server and get its directory + sessions + * Returns null if not a valid opencode server + */ async function verifyOpencodeServer(candidate: CandidatePort): Promise { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 500) try { + // Fetch project info const res = await fetch(`http://127.0.0.1:${candidate.port}/project/current`, { signal: controller.signal, }) @@ -46,6 +69,7 @@ async function verifyOpencodeServer(candidate: CandidatePort): Promise controller.abort(), 300) @@ -61,6 +85,7 @@ async function verifyOpencodeServer(candidate: CandidatePort): Promise s !== null) } +/** + * Run promises with limited concurrency + */ async function promiseAllSettledLimit(tasks: (() => Promise)[], limit: number): Promise { const results: T[] = [] let index = 0 @@ -114,7 +142,7 @@ async function promiseAllSettledLimit(tasks: (() => Promise)[], limit: num try { results[currentIndex] = await task() } catch { - // intentional no-op + // Swallow errors, results[currentIndex] stays undefined } } } diff --git a/apps/web/src/app/api/sse/[port]/route.ts b/apps/web/src/app/api/sse/[port]/route.ts index 150e6ec..b324ecd 100644 --- a/apps/web/src/app/api/sse/[port]/route.ts +++ b/apps/web/src/app/api/sse/[port]/route.ts @@ -11,10 +11,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const portNum = parseInt(port, 10) + // Validate port is in reasonable range if (portNum < 1024 || portNum > 65535) { return NextResponse.json({ error: "Port out of valid range" }, { status: 400 }) } + // Check if this is a manual (remote) server proxy port const manualServer = await getManualServerByProxyPort(portNum) const targetUrl = manualServer ? `${manualServer.url}/global/event` From 5630815fdb0cc98085c6d642b19620e629f579a0 Mon Sep 17 00:00:00 2001 From: Andrew Joslin Date: Fri, 16 Jan 2026 11:14:20 -0800 Subject: [PATCH 3/3] fix: update SSE route test for Headers object --- apps/web/src/app/api/sse/[port]/route.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/api/sse/[port]/route.test.ts b/apps/web/src/app/api/sse/[port]/route.test.ts index 3112a3c..be26f99 100644 --- a/apps/web/src/app/api/sse/[port]/route.test.ts +++ b/apps/web/src/app/api/sse/[port]/route.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" -import { GET } from "./route" import { NextRequest } from "next/server" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { GET } from "./route" describe("SSE Proxy Route /api/sse/[port]", () => { let originalFetch: typeof global.fetch @@ -142,12 +142,16 @@ describe("SSE Proxy Route /api/sse/[port]", () => { expect(response.body).toBe(mockStream) // Verify fetch was called with correct URL and headers - expect(global.fetch).toHaveBeenCalledWith("http://127.0.0.1:3000/global/event", { - headers: { - Accept: "text/event-stream", - "Cache-Control": "no-cache", - }, - }) + expect(global.fetch).toHaveBeenCalledWith( + "http://127.0.0.1:3000/global/event", + expect.objectContaining({ headers: expect.any(Headers) }), + ) + const callArgs = (global.fetch as ReturnType).mock.calls[0] as [ + string, + { headers: Headers }, + ] + expect(callArgs[1].headers.get("Accept")).toBe("text/event-stream") + expect(callArgs[1].headers.get("Cache-Control")).toBe("no-cache") }) it("accepts valid ports within range", async () => {