/** * Sourcebot adapter — real HTTP + config integration * (github.com/sourcebot-dev/sourcebot, YC F2025). * * Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT. * * Sourcebot is a self-hosted server that indexes repos declared in its * config.json and serves regex code search over `POST /api/search` (zoekt). So * the runtime drives it with plain HTTP + a config-file edit — no MCP: * - register_source: add `{ "type": "git", "url": "file:///abs/path" }` to the * server's config.json (it re-indexes automatically on config change). * - refresh: Sourcebot re-indexes on config change and on reindexIntervalMs; * there is no per-source trigger endpoint, so refresh reports current status. * - search: POST /api/search with a regex query, map files[] to hits. * - status: liveness GET against the base URL. * Declines the document ops (add/delete/export) — it is a whole-repo search index. * * Egress: a loopback base URL means the index runs on this machine, so no repo * content leaves it (local=true). A non-loopback base URL means content reaches * another host, so egress consent is required (local=false). */ import { existsSync, readFileSync, writeFileSync, renameSync } from "fs"; import { sha256Hex, writeReceipt } from "../egress-receipt.js"; import { assertCapability, assertEgressConsent, assertRequiredCapabilities, CodeProviderError, type CodeProvider, type CodeProviderCapability, type CodeSearchHit, type OpOptions, type RepoRef, type SearchOptions, type SourceRef, type SourceStatus, } from "./contract"; const CAPABILITIES: CodeProviderCapability[] = ["register_source", "refresh", "search", "status"]; const DEFAULT_URL = "http://localhost:3000"; const DEFAULT_TIMEOUT_MS = 30_000; type FetchLike = typeof globalThis.fetch; export interface SourcebotOptions { /** Base URL of the Sourcebot server. Defaults to SOURCEBOT_URL or http://localhost:3000. */ baseUrl?: string; /** Path to the server's config.json (for register_source). Defaults to SOURCEBOT_CONFIG. */ configPath?: string; /** * OPTIONAL API key for the Sourcebot REST API. Defaults to SOURCEBOT_API_KEY. * A local instance with anonymous access enabled * (`FORCE_ENABLE_ANONYMOUS_ACCESS=true`) serves `/api/search` with NO key — * verified keyless against a real Sourcebot v6.5.0. Only set a key when your * instance is login-gated; it is sent as `Authorization: Bearer `. */ apiKey?: string; /** Injectable fetch for tests. */ fetch?: FetchLike; env?: NodeJS.ProcessEnv; } function isLoopback(url: string): boolean { try { const h = new URL(url).hostname.toLowerCase(); return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost"); } catch { return false; } } export class SourcebotProvider implements CodeProvider { readonly id = "sourcebot" as const; readonly label = "Sourcebot"; readonly capabilities = new Set(CAPABILITIES); readonly local: boolean; readonly #baseUrl: string; readonly #configPath?: string; readonly #apiKey?: string; readonly #fetch: FetchLike; constructor(opts: SourcebotOptions = {}) { const env = opts.env ?? process.env; this.#baseUrl = (opts.baseUrl ?? env.SOURCEBOT_URL ?? DEFAULT_URL).replace(/\/$/, ""); this.#configPath = opts.configPath ?? env.SOURCEBOT_CONFIG; this.#apiKey = opts.apiKey ?? env.SOURCEBOT_API_KEY; this.#fetch = opts.fetch ?? globalThis.fetch; this.local = isLoopback(this.#baseUrl); assertRequiredCapabilities(this.id, this.capabilities); } #authHeaders(): Record { return this.#apiKey ? { Authorization: `Bearer ${this.#apiKey}` } : {}; } has(capability: CodeProviderCapability): boolean { return this.capabilities.has(capability); } /** * Add the repo as a local `git` connection in Sourcebot's config.json. * NOTE: Sourcebot silently SKIPS a local repo that has no `remote.origin.url` * (logs "Skipping - remote.origin.url not found"); a freshly `git init`'d * repo must set an origin before it will index. */ async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise { assertEgressConsent(this, opts); // no-op when the server is loopback (local) if (!this.#configPath) { throw new CodeProviderError( "PROVIDER_UNAVAILABLE", "set SOURCEBOT_CONFIG to the server's config.json path to register sources", this.id, ); } let config: { connections?: Record }; try { config = existsSync(this.#configPath) ? (JSON.parse(readFileSync(this.#configPath, "utf-8")) as typeof config) : {}; } catch (err) { throw new CodeProviderError("PROVIDER_ERROR", `unreadable Sourcebot config: ${(err as Error).message}`, this.id); } config.connections = config.connections ?? {}; config.connections[repo.id] = { type: "git", url: `file://${repo.path}` }; // Atomic write so a running Sourcebot never reads a half-written config. const tmp = `${this.#configPath}.tmp.${process.pid}`; writeFileSync(tmp, JSON.stringify(config, null, 2), "utf-8"); renameSync(tmp, this.#configPath); return { id: repo.id, state: "registered", detail: "Sourcebot re-indexes on config change" }; } /** * Sourcebot re-indexes automatically; report current liveness. Refresh is a * write-class op (it represents indexing repo content into the server), so a * non-loopback server requires per-repo consent even though the HTTP call * below is only the liveness probe. */ async refresh(source: SourceRef, opts: OpOptions = {}): Promise { assertEgressConsent(this, opts); // no-op when the server is loopback (local) const live = await this.status(source, opts); return { ...live, detail: "Sourcebot re-indexes automatically (config change + reindexIntervalMs)" }; } async search(query: string, opts: SearchOptions = {}): Promise { if (!query.trim()) return []; // Query text is repo-derived content (sensitive class): a non-loopback // server needs per-repo consent BEFORE the query is sent. Fail-closed — // the throw happens before any bytes (or any receipt) exist. assertEgressConsent(this, opts); // no-op when the server is loopback (local) const body = { query: opts.source ? `repo:${opts.source} ${query}` : query, matches: opts.limit ?? 20, isRegexEnabled: true, isCaseSensitivityEnabled: false, }; const payload = await this.#post("/api/search", body, opts); return parseSourcebotSearch(payload, opts.limit ?? 20); } async status(_source?: SourceRef, opts: OpOptions = {}): Promise { // `redirect: manual` so an auth-gated server (307 -> /login) reads as // not-usable instead of following to a 200 and falsely reporting "ready". // The probe body is a FIXED literal (query "sourcebot") — no repo-derived // content — so it is allowed without consent; the receipt records that // consent was unchecked instead of pretending it was verified. try { const res = await this.#fetchWithTimeout( `${this.#baseUrl}/api/search`, { method: "POST", headers: { "Content-Type": "application/json", ...this.#authHeaders() }, body: JSON.stringify({ query: "sourcebot", matches: 1, isRegexEnabled: false }), redirect: "manual" }, opts.timeout ?? DEFAULT_TIMEOUT_MS, { payloadClass: "liveness-probe (fixed query, no repo-derived content)", consented: opts.consented === true, env: opts.env }, ); if (res.status === 401 || res.status === 403) { return { id: "*", state: "unknown", partial: true, detail: "reachable but login-gated (enable anonymous access with FORCE_ENABLE_ANONYMOUS_ACCESS=true for local use, or set SOURCEBOT_API_KEY)" }; } return { id: "*", state: res.ok ? "ready" : "unknown", partial: true, detail: `HTTP ${res.status}` }; } catch { return { id: "*", state: "unknown", partial: true, detail: `unreachable at ${this.#baseUrl}` }; } } async #post(path: string, body: unknown, opts: OpOptions): Promise { let res: Response; try { res = await this.#fetchWithTimeout(`${this.#baseUrl}${path}`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", ...this.#authHeaders() }, body: JSON.stringify(body), }, opts.timeout ?? DEFAULT_TIMEOUT_MS, { payloadClass: "code-search-request", consented: opts.consented === true, env: opts.env, }); } catch (err) { if (err instanceof CodeProviderError) throw err; throw new CodeProviderError("PROVIDER_UNAVAILABLE", `Sourcebot unreachable at ${this.#baseUrl}: ${(err as Error).message}`, this.id); } if (res.status === 401 || res.status === 403) { throw new CodeProviderError("PROVIDER_UNAVAILABLE", `Sourcebot is login-gated (HTTP ${res.status}); enable anonymous access (FORCE_ENABLE_ANONYMOUS_ACCESS=true) for local use, or set SOURCEBOT_API_KEY`, this.id); } if (!res.ok) throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot ${path} returned HTTP ${res.status}`, this.id); try { return await res.json(); } catch (err) { throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot returned non-JSON: ${(err as Error).message}`, this.id); } } async #fetchWithTimeout( url: string, init: RequestInit, timeout: number, receipt: { payloadClass: string; consented: boolean; env?: NodeJS.ProcessEnv }, ): Promise { // Every Sourcebot HTTP call routes through here. A loopback server keeps // content on this machine (no egress, no receipt); a non-loopback server // is an off-machine send and gets a fail-closed receipt BEFORE the fetch. // The receipt's consent field records the ACTUAL consent state passed by // the op (opts.consented) — the tamper-evident ledger must never attest // "consented=true" for a send where nothing checked consent. Content- // bearing ops (search/refresh) assert consent before reaching here; the // status liveness probe is allowed unconsented and its receipt says so. if (!this.local) { const body = typeof init.body === "string" ? init.body : ""; writeReceipt({ env: receipt.env, sink: "sourcebot", host: new URL(url).host, payloadClass: receipt.payloadClass, bytes: Buffer.byteLength(body), sha256: sha256Hex(body), consent: receipt.consented ? "code-intelligence provider=sourcebot (non-loopback SOURCEBOT_URL) + per-repo consented=true" : "code-intelligence provider=sourcebot (non-loopback SOURCEBOT_URL) + consent=unchecked (liveness probe only; content-bearing ops assert consent before sending)", }); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { return await this.#fetch(url, { ...init, signal: controller.signal }); } catch (err) { if ((err as Error)?.name === "AbortError") throw new CodeProviderError("PROVIDER_TIMEOUT", "Sourcebot request timed out", this.id); throw err; } finally { clearTimeout(timer); } } } interface SourcebotMatchRange { start?: { lineNumber?: number } } interface SourcebotChunk { content?: string; matchRanges?: SourcebotMatchRange[] } interface SourcebotFile { fileName?: { text?: string }; repository?: string; chunks?: SourcebotChunk[] } /** * Map a `POST /api/search` response `{ files: [...] }` to hits: one hit per file, * ref = file path, snippet = first matching chunk, kind = "file". Tolerant of a * missing/garbage payload (returns []). Exported for deterministic testing. */ export function parseSourcebotSearch(payload: unknown, limit: number): CodeSearchHit[] { const files = (payload as { files?: unknown })?.files; if (!Array.isArray(files)) return []; const hits: CodeSearchHit[] = []; for (const f of files as SourcebotFile[]) { const ref = f?.fileName?.text; if (typeof ref !== "string") continue; const chunk = f.chunks?.[0]; const line = chunk?.matchRanges?.[0]?.start?.lineNumber; hits.push({ ref: typeof line === "number" ? `${ref}:${line}` : ref, snippet: typeof chunk?.content === "string" ? chunk.content.trim() : undefined, kind: "file", }); if (hits.length >= limit) break; } return hits; }