mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 15:09:00 +02:00
fix(code-intelligence): consent that means what it says — polarity, receipts, read-only veto
Four review findings on the wave's own Phase 1 port, all red-first: 'consent <repo> no' recorded consent GRANTED (the CLI ignored the argument and always wrote true) — yes|no is now required and garbage records nothing; Sourcebot egress receipts claimed consented=true on paths that never checked consent — the actual consent state is threaded into every receipt, search is fail-closed on non-loopback, and the liveness probe's receipt says truthfully that it sends no repo content; repoPolicyVeto only honored the deny tier while gbrain refresh writes pages — write-class ops now veto on read-only too, matching the sync chokepoint, via one shared lib/gbrain-repo-policy-client.ts (win32 bash invocation, spawn-vs-unreadable error distinction) used by both call sites. Also: source ids get a host+path hash (same-name repos no longer collide), refresh timeout raised to 120s, availability probes run concurrently at 3s, graphify status stops JSON.parsing 100MB graphs for a count, and every ported file carries the fork MIT notice. +15 tests across the two suites.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* code-intelligence/contract — the OPTIONAL, repo-oriented provider contract.
|
||||
*
|
||||
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||
*
|
||||
* gstack does not maintain a home-grown indexer. It defines this small contract
|
||||
* and external providers (GBrain, Sourcebot, Graphify) implement it. The whole
|
||||
* contract is OPTIONAL: when no provider is available/consented,
|
||||
@@ -17,6 +19,16 @@
|
||||
|
||||
export type CodeProviderId = "gbrain" | "sourcebot" | "graphify";
|
||||
|
||||
/**
|
||||
* Policy op classification for the per-remote trust-tier veto (selection.ts).
|
||||
* Write-class ops (register_source / index / refresh / add / delete) cause
|
||||
* pages to be written, so BOTH `deny` and `read-only` tiers veto them — the
|
||||
* same semantics as runCodeImport in bin/gstack-gbrain-sync.ts ("code ingest
|
||||
* writes pages"). Read-class ops (search / export / status) write nothing, so
|
||||
* only `deny` vetoes them. Callers that don't say get "write" — fail-closed.
|
||||
*/
|
||||
export type OpClass = "read" | "write";
|
||||
|
||||
export type CodeProviderCapability =
|
||||
| "register_source"
|
||||
| "refresh"
|
||||
@@ -72,16 +84,18 @@ export interface CodeSearchHit {
|
||||
|
||||
export interface OpOptions {
|
||||
/**
|
||||
* Env override for spawned processes. Production callers leave this unset;
|
||||
* tests inject a synthetic env (fake CLI on PATH). Matches the existing
|
||||
* gbrain helpers.
|
||||
* Env override for spawned processes and egress-receipt home resolution.
|
||||
* Production callers leave this unset; tests inject a synthetic env (fake
|
||||
* CLI on PATH, temp GSTACK_HOME). Matches the existing gbrain helpers.
|
||||
*/
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Timeout in ms for the underlying op. */
|
||||
timeout?: number;
|
||||
/**
|
||||
* Explicit per-repo consent that repo content may leave the machine. Required
|
||||
* for non-local providers on register_source / refresh / add.
|
||||
* for non-local providers on register_source / refresh / add / search (the
|
||||
* search query text is repo-derived content). The recorded value also feeds
|
||||
* the egress receipt, which attests the ACTUAL consent state — never assumed.
|
||||
*/
|
||||
consented?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* GBrain adapter — full contract fit over the existing gbrain CLI chokepoint.
|
||||
*
|
||||
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||
*
|
||||
* Reuses lib/gbrain-exec.ts (spawnGbrain, seeded DATABASE_URL) and
|
||||
* lib/gbrain-sources.ts (ensureSourceRegistered, probeSource, sourcePageCount)
|
||||
* rather than re-issuing raw commands, so the DATABASE_URL / GBRAIN_HOME /
|
||||
@@ -39,6 +41,23 @@ const CAPABILITIES: CodeProviderCapability[] = [
|
||||
];
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
/**
|
||||
* refresh() default. Full code indexing on the 1000+-tracked-file repos this
|
||||
* feature targets routinely outruns the 30s op default; GraphifyProvider uses
|
||||
* the same 120s ceiling for the same indexing work. Query/status stay at 30s.
|
||||
*/
|
||||
const REFRESH_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* Environmental (engine / DB / config) failure shapes, shared by #assertOk and
|
||||
* #wrap so the two paths can never drift. These degrade to
|
||||
* PROVIDER_UNAVAILABLE (caller falls back to grep / file-only), not a hard
|
||||
* PROVIDER_ERROR with a raw dump. Covers the real case where gbrain's pglite
|
||||
* engine fails to init its WASM runtime (garrytan/gbrain#223) as well as
|
||||
* unreachable/unconfigured databases and a missing CLI.
|
||||
*/
|
||||
const ENVIRONMENTAL_ERROR_RE =
|
||||
/not on PATH|command not found|PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json|database (is )?un(reachable|available)/i;
|
||||
|
||||
/**
|
||||
* Parse `gbrain search` text output (`[score] slug -- snippet`) into hits.
|
||||
@@ -110,7 +129,7 @@ export class GbrainProvider implements CodeProvider {
|
||||
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
assertEgressConsent(this, opts);
|
||||
this.#receipt("repo-code-index (sent by gbrain subprocess)", opts);
|
||||
const timeout = opts.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
const timeout = opts.timeout ?? REFRESH_TIMEOUT_MS;
|
||||
// Two passes, verified end-to-end against real Postgres-backed gbrain 0.42.56:
|
||||
// 1. default sync (markdown strategy) — indexes docs.
|
||||
// 2. `sync --strategy code` — the ACTUAL code-indexing pass. Without it code
|
||||
@@ -219,10 +238,8 @@ export class GbrainProvider implements CodeProvider {
|
||||
throw new CodeProviderError("PROVIDER_TIMEOUT", "gbrain timed out", this.id);
|
||||
}
|
||||
// Engine / DB / config problems are ENVIRONMENTAL — degrade to UNAVAILABLE
|
||||
// (caller falls back to file-only), not a hard PROVIDER_ERROR with a raw dump.
|
||||
// Covers the real case where gbrain's pglite engine fails to init its WASM
|
||||
// runtime (garrytan/gbrain#223) as well as unreachable/unconfigured databases.
|
||||
if (/PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json|database (is )?un(reachable|available)/i.test(stderr)) {
|
||||
// (caller falls back to file-only). Shapes hoisted to ENVIRONMENTAL_ERROR_RE.
|
||||
if (ENVIRONMENTAL_ERROR_RE.test(stderr)) {
|
||||
throw new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(stderr) || "gbrain engine unavailable", this.id);
|
||||
}
|
||||
throw new CodeProviderError("PROVIDER_ERROR", firstLine(stderr) || `gbrain exited ${r.status}`, this.id);
|
||||
@@ -231,9 +248,9 @@ export class GbrainProvider implements CodeProvider {
|
||||
#wrap(err: unknown): CodeProviderError {
|
||||
if (err instanceof CodeProviderError) return err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Same environmental-vs-real split as #assertOk: missing CLI, or engine/DB/
|
||||
// config problems, degrade to UNAVAILABLE so callers fall back to file-only.
|
||||
if (/not on PATH|command not found|PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json/i.test(message)) {
|
||||
// Same environmental-vs-real split as #assertOk — literally the same
|
||||
// regex (ENVIRONMENTAL_ERROR_RE), so the two paths can never drift.
|
||||
if (ENVIRONMENTAL_ERROR_RE.test(message)) {
|
||||
return new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(message), this.id);
|
||||
}
|
||||
return new CodeProviderError("PROVIDER_ERROR", firstLine(message), this.id);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Graphify adapter — real CLI integration (github.com/Graphify-Labs/graphify).
|
||||
*
|
||||
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||
*
|
||||
* Graphify is a LOCAL tree-sitter knowledge graph. For CODE, `graphify <dir>`
|
||||
* and `graphify update <dir>` produce the SAME AST graph with NO LLM and NO
|
||||
* network (verified against graphify 0.9.23 — both emit `AST extraction on N
|
||||
@@ -26,7 +28,7 @@
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
import {
|
||||
assertCapability,
|
||||
@@ -47,6 +49,13 @@ const OUT_DIR = "graphify-out";
|
||||
const GRAPH_JSON = "graph.json";
|
||||
const DEFAULT_TIMEOUT_MS = 120_000; // indexing a repo can take a while
|
||||
const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32"; // graphify is a shim on Windows
|
||||
/**
|
||||
* status() only parses graph.json for a node count when the file is at most
|
||||
* this big. On the 1000+-file repos this feature targets, graph.json can run
|
||||
* to hundreds of MB — JSON.parsing that for a cosmetic count is a heap spike.
|
||||
* Above the threshold the display reports the file size instead.
|
||||
*/
|
||||
const STATUS_PARSE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export interface GraphifyOptions {
|
||||
/** Directory whose `graphify-out/` search/status/export read. Defaults to cwd. */
|
||||
@@ -127,13 +136,21 @@ export class GraphifyProvider implements CodeProvider {
|
||||
const graphPath = join(dir, OUT_DIR, GRAPH_JSON);
|
||||
if (!existsSync(graphPath)) return { id: dir, state: "absent" };
|
||||
let itemCount: number | undefined;
|
||||
let detail = graphPath;
|
||||
try {
|
||||
const graph = JSON.parse(readFileSync(graphPath, "utf-8")) as { nodes?: unknown[] };
|
||||
if (Array.isArray(graph.nodes)) itemCount = graph.nodes.length;
|
||||
// stat first: parse the whole graph only when it's small (the node count
|
||||
// is display-only, never worth a hundreds-of-MB JSON.parse heap spike).
|
||||
const size = statSync(graphPath).size;
|
||||
if (size <= STATUS_PARSE_MAX_BYTES) {
|
||||
const graph = JSON.parse(readFileSync(graphPath, "utf-8")) as { nodes?: unknown[] };
|
||||
if (Array.isArray(graph.nodes)) itemCount = graph.nodes.length;
|
||||
} else {
|
||||
detail = `${graphPath} (${(size / (1024 * 1024)).toFixed(1)} MB graph; node count skipped)`;
|
||||
}
|
||||
} catch {
|
||||
// graph.json present but unparseable — still ready, just no count.
|
||||
// graph.json present but unstatable/unparseable — still ready, just no count.
|
||||
}
|
||||
return { id: dir, state: "ready", itemCount, detail: graphPath };
|
||||
return { id: dir, state: "ready", itemCount, detail };
|
||||
}
|
||||
|
||||
async export(source: SourceRef, _opts: OpOptions = {}): Promise<string> {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* code-intelligence — the OPTIONAL, repo-oriented provider contract.
|
||||
*
|
||||
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||
*
|
||||
* See docs/designs/CODE_INTELLIGENCE_PROVIDER_CONTRACT.md.
|
||||
*/
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Picker — constructs the code-intelligence provider the user selected, and
|
||||
* offers the recommendation order (GBrain first) for the selection UX.
|
||||
*
|
||||
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||
*
|
||||
* `resolveSelectedProvider()` reads the persisted selection and constructs that
|
||||
* provider, or returns null when nothing is selected — the provider-OFF path,
|
||||
* where callers degrade to grep / the file-only decision store. Availability is
|
||||
@@ -60,41 +62,49 @@ export interface Availability {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Availability probes are DISPLAY probes — they must never stall the CLI. A
|
||||
* dead non-loopback SOURCEBOT_URL at the adapters' 30s op default meant a 30s
|
||||
* hang just to print the options table; 3s is plenty for a liveness check.
|
||||
*/
|
||||
const PROBE_TIMEOUT_MS = 3_000;
|
||||
|
||||
/**
|
||||
* Probe which providers are usable right now, in recommendation order. Used by
|
||||
* the `options`/`status` display. GBrain via the real localEngineStatus();
|
||||
* Graphify via its CLI status; Sourcebot via an HTTP liveness probe.
|
||||
* Graphify via its CLI status; Sourcebot via an HTTP liveness probe. The three
|
||||
* probes are independent, so they run concurrently, each capped at
|
||||
* PROBE_TIMEOUT_MS (localEngineStatus owns its own probe timeout + cache).
|
||||
*/
|
||||
export async function detectAvailable(opts: PickerOptions = {}): Promise<Availability[]> {
|
||||
const gbrainStatus = localEngineStatus({ env: opts.env });
|
||||
const gbrainOk = gbrainStatus === "ok" || gbrainStatus === "timeout";
|
||||
|
||||
// Available = the CLI is installed and selectable (NOT "a graph already exists
|
||||
// here"). A freshly installed Graphify with no graph yet is still available.
|
||||
const graphifyOk = graphifyInstalled(opts.env);
|
||||
let graphifyDetail = "graphify CLI not installed (pip install graphifyy, Python >= 3.10)";
|
||||
if (graphifyOk) {
|
||||
try {
|
||||
const s = await new GraphifyProvider({ env: opts.env, ...opts.graphify }).status();
|
||||
graphifyDetail = s.state === "ready" ? "installed; graph built in this repo" : "installed; run `index` to build a graph";
|
||||
} catch {
|
||||
graphifyDetail = "installed";
|
||||
}
|
||||
}
|
||||
|
||||
let sourcebotOk = false;
|
||||
let sourcebotDetail = "server unreachable";
|
||||
try {
|
||||
const s = await new SourcebotProvider({ env: opts.env, ...opts.sourcebot }).status();
|
||||
sourcebotOk = s.state === "ready";
|
||||
sourcebotDetail = s.detail ?? "";
|
||||
} catch {
|
||||
sourcebotOk = false;
|
||||
}
|
||||
|
||||
return [
|
||||
{ id: "gbrain", available: gbrainOk, detail: `gbrain engine: ${gbrainStatus}` },
|
||||
{ id: "sourcebot", available: sourcebotOk, detail: sourcebotDetail },
|
||||
{ id: "graphify", available: graphifyOk, detail: graphifyDetail },
|
||||
];
|
||||
const [gbrain, sourcebot, graphify] = await Promise.all([
|
||||
(async (): Promise<Availability> => {
|
||||
const status = localEngineStatus({ env: opts.env });
|
||||
return { id: "gbrain", available: status === "ok" || status === "timeout", detail: `gbrain engine: ${status}` };
|
||||
})(),
|
||||
(async (): Promise<Availability> => {
|
||||
try {
|
||||
const s = await new SourcebotProvider({ env: opts.env, ...opts.sourcebot }).status(undefined, { timeout: PROBE_TIMEOUT_MS });
|
||||
return { id: "sourcebot", available: s.state === "ready", detail: s.detail ?? "" };
|
||||
} catch {
|
||||
return { id: "sourcebot", available: false, detail: "server unreachable" };
|
||||
}
|
||||
})(),
|
||||
(async (): Promise<Availability> => {
|
||||
// Available = the CLI is installed and selectable (NOT "a graph already exists
|
||||
// here"). A freshly installed Graphify with no graph yet is still available.
|
||||
const installed = graphifyInstalled(opts.env);
|
||||
let detail = "graphify CLI not installed (pip install graphifyy, Python >= 3.10)";
|
||||
if (installed) {
|
||||
try {
|
||||
const s = await new GraphifyProvider({ env: opts.env, ...opts.graphify }).status(undefined, { timeout: PROBE_TIMEOUT_MS });
|
||||
detail = s.state === "ready" ? "installed; graph built in this repo" : "installed; run `index` to build a graph";
|
||||
} catch {
|
||||
detail = "installed";
|
||||
}
|
||||
}
|
||||
return { id: "graphify", available: installed, detail };
|
||||
})(),
|
||||
]);
|
||||
return [gbrain, sourcebot, graphify];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* per-repo indexing consent. Stored at `$GSTACK_HOME/code-intelligence.json`
|
||||
* (default `~/.gstack/`), the same home the rest of gstack uses.
|
||||
*
|
||||
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||
*
|
||||
* Consent is per-repo (keyed by absolute repo path), because indexing consent
|
||||
* is "may THIS repo's content be indexed by the selected provider" — a decision
|
||||
* a user makes per project, not once for the machine. No selection at all is the
|
||||
@@ -12,8 +14,9 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
import type { CodeProviderId } from "./contract";
|
||||
import { execFileSync } from "child_process";
|
||||
import { hasRepoPolicyStore, repoPolicyTier } from "../gbrain-repo-policy-client";
|
||||
import type { CodeProviderId, OpClass } from "./contract";
|
||||
|
||||
export interface Selection {
|
||||
provider: CodeProviderId | null;
|
||||
@@ -76,14 +79,20 @@ export function setConsent(repoPath: string, consented: boolean, env: NodeJS.Pro
|
||||
* The per-remote trust store (gstack-gbrain-repo-policy) is the SINGLE
|
||||
* authority for consent-to-send: a `deny` tier vetoes any recorded
|
||||
* code-intelligence consent, so two stores can never disagree about whether
|
||||
* code may leave this repo (R1, fork port wave 2 review). Mirrors the
|
||||
* gbrain-sync chokepoint's polarity: no policy store → no veto (nothing was
|
||||
* ever set); unreadable store → veto (fail-closed — a policy the user set
|
||||
* must not be bypassed by a broken store).
|
||||
* code may leave this repo (R1, fork port wave 2 review). The veto is
|
||||
* op-class-aware (R2): `read-only` means "search allowed, page writes never"
|
||||
* (the exact semantics runCodeImport in bin/gstack-gbrain-sync.ts enforces —
|
||||
* code ingest writes pages), so it vetoes write-class ops (register / index /
|
||||
* refresh / add / delete) while read-class ops (search / export / status)
|
||||
* pass; `deny` vetoes both classes. Mirrors the gbrain-sync chokepoint's
|
||||
* polarity: no policy store → no veto (nothing was ever set); unreadable
|
||||
* store OR unspawnable policy helper → veto for every op class (fail-closed —
|
||||
* a policy the user set must not be bypassed by a broken store or a helper
|
||||
* that can't run). Reads through the shared lib/gbrain-repo-policy-client.ts
|
||||
* so this site and the gbrain-sync gate can never drift.
|
||||
*/
|
||||
function repoPolicyVeto(repoPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const home = env.GSTACK_HOME || join(homedir(), ".gstack");
|
||||
if (!existsSync(join(home, "gbrain-repo-policy.json"))) return false;
|
||||
function repoPolicyVeto(repoPath: string, opClass: OpClass, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (!hasRepoPolicyStore(env)) return false; // fast path: nothing was ever set — skip the git spawn too
|
||||
let url = "";
|
||||
try {
|
||||
url = execFileSync("git", ["-C", resolve(repoPath), "remote", "get-url", "origin"], {
|
||||
@@ -93,17 +102,21 @@ function repoPolicyVeto(repoPath: string, env: NodeJS.ProcessEnv = process.env):
|
||||
return false; // no remote → policy (keyed by remote) has nothing set for this repo
|
||||
}
|
||||
if (!url) return false;
|
||||
const res = spawnSync(join(import.meta.dir, "..", "..", "bin", "gstack-gbrain-repo-policy"), ["get", url], {
|
||||
encoding: "utf-8", timeout: 10_000, env: { ...env } as NodeJS.ProcessEnv,
|
||||
});
|
||||
if (res.error || res.status !== 0) return true; // fail-closed
|
||||
const tier = (res.stdout || "").trim();
|
||||
return tier === "deny";
|
||||
const res = repoPolicyTier(url, env);
|
||||
if (res.error) return true; // fail-closed (unreadable store or spawn failure alike)
|
||||
if (res.tier === "deny") return true; // deny beats consent for every op class
|
||||
return res.tier === "read-only" && opClass === "write"; // read-only: writes never, reads pass
|
||||
}
|
||||
|
||||
export function hasConsent(repoPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
/**
|
||||
* Recorded per-repo consent, filtered through the repo-policy veto. `opClass`
|
||||
* defaults to "write" so a caller that doesn't classify its op gets the
|
||||
* fail-closed answer; pass "read" only for ops that write no pages (search /
|
||||
* export / status).
|
||||
*/
|
||||
export function hasConsent(repoPath: string, env: NodeJS.ProcessEnv = process.env, opClass: OpClass = "write"): boolean {
|
||||
if (readSelection(env).consents[resolve(repoPath)] !== true) return false;
|
||||
return !repoPolicyVeto(repoPath, env);
|
||||
return !repoPolicyVeto(repoPath, opClass, env);
|
||||
}
|
||||
|
||||
/** Record the repo path a provider last indexed, so search reads the same graph. */
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* 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:
|
||||
@@ -128,32 +130,46 @@ export class SourcebotProvider implements CodeProvider {
|
||||
return { id: repo.id, state: "registered", detail: "Sourcebot re-indexes on config change" };
|
||||
}
|
||||
|
||||
/** Sourcebot re-indexes automatically; report current liveness. */
|
||||
/**
|
||||
* 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<SourceStatus> {
|
||||
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<CodeSearchHit[]> {
|
||||
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.timeout ?? DEFAULT_TIMEOUT_MS);
|
||||
const payload = await this.#post("/api/search", body, opts);
|
||||
return parseSourcebotSearch(payload, opts.limit ?? 20);
|
||||
}
|
||||
|
||||
async status(_source?: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
// `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)" };
|
||||
@@ -164,15 +180,20 @@ export class SourcebotProvider implements CodeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async #post(path: string, body: unknown, timeout: number): Promise<unknown> {
|
||||
async #post(path: string, body: unknown, opts: OpOptions): Promise<unknown> {
|
||||
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),
|
||||
}, timeout);
|
||||
}, 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) {
|
||||
@@ -186,19 +207,32 @@ export class SourcebotProvider implements CodeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchWithTimeout(url: string, init: RequestInit, timeout: number): Promise<Response> {
|
||||
async #fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeout: number,
|
||||
receipt: { payloadClass: string; consented: boolean; env?: NodeJS.ProcessEnv },
|
||||
): Promise<Response> {
|
||||
// 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: "code-search-request",
|
||||
payloadClass: receipt.payloadClass,
|
||||
bytes: Buffer.byteLength(body),
|
||||
sha256: sha256Hex(body),
|
||||
consent: "code-intelligence provider=sourcebot (non-loopback SOURCEBOT_URL) + per-repo consented=true",
|
||||
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();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* suggest — should the session-start indexing offer be made for this repo?
|
||||
*
|
||||
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||
*
|
||||
* The offer fires at most once per machine: never when a provider is already
|
||||
* selected, never after an explicit decline (`select none`), and never for
|
||||
* small repos where grep is already fast. Detection is cheap and local
|
||||
@@ -11,7 +13,7 @@ import { spawnSync } from "child_process";
|
||||
import { resolve } from "path";
|
||||
import { readSelection } from "./selection";
|
||||
|
||||
// ponytail: single tracked-file-count knob for "large"; add a LOC signal if it misfires
|
||||
// Single tracked-file-count knob for "large"; add a LOC signal if it misfires.
|
||||
/** Tracked-file count at which indexing starts paying for itself. */
|
||||
export const LARGE_REPO_FILE_THRESHOLD = 1000;
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* gbrain-repo-policy-client — the ONE TypeScript client for the per-remote
|
||||
* trust store (bin/gstack-gbrain-repo-policy, a bash CLI that owns URL
|
||||
* normalization and schema migration — do not reimplement either here).
|
||||
*
|
||||
* Extracted because two call sites (lib/code-intelligence/selection.ts consent
|
||||
* veto; bin/gstack-gbrain-sync.ts code-import gate) each spawnSync'd the script
|
||||
* themselves and had started to drift. On win32, spawning a
|
||||
* `#!/usr/bin/env bash` script directly fails ENOENT, which both sites'
|
||||
* fail-closed paths then reported as "store could not be read" for EVERY repo
|
||||
* — so this client invokes the script through `bash` there (an ENOENT then
|
||||
* genuinely means "no bash on PATH") and reports a spawn failure distinctly
|
||||
* from a policy-read failure, so callers can say what actually broke.
|
||||
*
|
||||
* POLARITY IS THE CALLER'S. This client only reads and classifies; each call
|
||||
* site keeps its own fail-open / fail-closed decision on `error`.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
export type RepoPolicyTierValue = "deny" | "read-only" | "read-write" | "none";
|
||||
|
||||
export interface RepoPolicyResult {
|
||||
/** `none` = no policy store, no remote URL, or no entry for this remote. */
|
||||
tier: RepoPolicyTierValue;
|
||||
/**
|
||||
* Set when the tier could not be determined (tier is `none` then):
|
||||
* - `spawn-failed`: the policy script could not be executed at all
|
||||
* (script missing, or no bash on PATH on win32) — the store itself may
|
||||
* be perfectly fine.
|
||||
* - `unreadable`: the script ran but could not read the store
|
||||
* (permissions, corruption, unexpected output).
|
||||
*/
|
||||
error?: "unreadable" | "spawn-failed";
|
||||
}
|
||||
|
||||
/** Absolute path of the policy store for this env (GSTACK_HOME-aware). */
|
||||
export function repoPolicyStorePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const home = env.GSTACK_HOME || join(env.HOME || homedir(), ".gstack");
|
||||
return join(home, "gbrain-repo-policy.json");
|
||||
}
|
||||
|
||||
/** No store on disk = no policy was ever set (the fast path — no subprocess). */
|
||||
export function hasRepoPolicyStore(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return existsSync(repoPolicyStorePath(env));
|
||||
}
|
||||
|
||||
/** The bash script that owns the store — resolved relative to this file (lib/ → bin/), never cwd. */
|
||||
const POLICY_SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
|
||||
|
||||
/**
|
||||
* Trust tier for a remote URL, via `gstack-gbrain-repo-policy get <url>`.
|
||||
*
|
||||
* Fast paths (no subprocess): no store on disk → `none`; no remote URL →
|
||||
* `none` (policy is keyed by origin remote, so nothing can be set for the
|
||||
* repo). Everything else shells to the script, which owns normalization.
|
||||
*/
|
||||
export function repoPolicyTier(url: string | null, env: NodeJS.ProcessEnv = process.env): RepoPolicyResult {
|
||||
if (!hasRepoPolicyStore(env)) return { tier: "none" };
|
||||
if (!url) return { tier: "none" };
|
||||
// The script is `#!/usr/bin/env bash`; win32 can't exec a shebang file, so
|
||||
// invoke through bash there. An ENOENT then means bash is not on PATH.
|
||||
const [cmd, args]: [string, string[]] =
|
||||
process.platform === "win32" ? ["bash", [POLICY_SCRIPT, "get", url]] : [POLICY_SCRIPT, ["get", url]];
|
||||
const res = spawnSync(cmd, args, {
|
||||
encoding: "utf-8",
|
||||
timeout: 10_000,
|
||||
// Explicit env: Bun's spawnSync default env snapshot misses runtime
|
||||
// process.env mutations (e.g. tests redirecting GSTACK_HOME).
|
||||
env: { ...env } as NodeJS.ProcessEnv,
|
||||
});
|
||||
if (res.error) {
|
||||
const code = (res.error as NodeJS.ErrnoException).code;
|
||||
return { tier: "none", error: code === "ENOENT" ? "spawn-failed" : "unreadable" };
|
||||
}
|
||||
if (res.status !== 0) return { tier: "none", error: "unreadable" };
|
||||
const tier = (res.stdout || "").trim();
|
||||
if (tier === "deny" || tier === "read-only" || tier === "read-write") return { tier };
|
||||
if (tier === "unset") return { tier: "none" };
|
||||
return { tier: "none", error: "unreadable" }; // unexpected output — a read failure, not a tier
|
||||
}
|
||||
Reference in New Issue
Block a user