From f20cd9a3a37a8681711db03e1ec0fbe216bc2de9 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:41:22 +0000 Subject: [PATCH] A3: keep a project's DATABASE_URL away from a PGLite brain With a PGLite config, buildGbrainEnv returned the caller env unchanged, so a project's .env DATABASE_URL (autoloaded by Bun) reached gbrain: a false broken-db at best, gbrain talking to the app's own Postgres at worst. Inherited DATABASE_URL and GBRAIN_DATABASE_URL are now blanked for PGLite configs (GSTACK_RESPECT_ENV_DATABASE_URL=1 still keeps them), and every gbrain child runs from gbrain's config dir instead of the user's project, so it cannot reload the project's .env / .env.local. The old test that pinned pass-through for an engine:pglite config is migrated: pass-through now applies only to configs that name neither a database_url nor PGLite. Co-authored-by: maxpetrusenkoagent --- lib/gbrain-exec.ts | 39 ++++++++++++++++--- lib/gbrain-local-status.ts | 3 +- lib/gbrain-sources.ts | 6 ++- test/build-gbrain-env.test.ts | 71 ++++++++++++++++++++++++++++++++++- 4 files changed, 109 insertions(+), 10 deletions(-) diff --git a/lib/gbrain-exec.ts b/lib/gbrain-exec.ts index 71d502880..a9ad36354 100644 --- a/lib/gbrain-exec.ts +++ b/lib/gbrain-exec.ts @@ -35,11 +35,13 @@ import { existsSync, readFileSync } from "fs"; import { join } from "path"; -import { homedir } from "os"; +import { homedir, tmpdir } from "os"; import { spawnSync, spawn, execFileSync, type SpawnSyncReturns, type ChildProcess, type SpawnOptions } from "child_process"; interface GbrainConfig { database_url?: string; + engine?: string; + database_path?: string; } export interface BuildGbrainEnvOptions { @@ -95,9 +97,15 @@ export function gbrainConfigDir(env: NodeJS.ProcessEnv = process.env): string { * unchanged when: * - `GSTACK_RESPECT_ENV_DATABASE_URL=1` (intentional opt-out), * - the config file is missing or unparseable, - * - the config has no `database_url`, + * - the config has no `database_url` and is not a PGLite config, * - the caller already set DATABASE_URL to the same value. * + * A PGLite config has no URL to seed, so an inherited DATABASE_URL / + * GBRAIN_DATABASE_URL (Bun autoloads a project's `.env`) would make gbrain + * connect to the app's own database instead of its file brain (#1917). Both + * are blanked, not deleted: gbrain treats an empty value as unset, and an + * existing (empty) variable is never refilled by a child's dotenv autoload. + * * GBRAIN_PREPARE is never set here (#1965): gbrain auto-disables prepared * statements on transaction-mode poolers itself, and forcing them on breaks * every write with "prepared statement does not exist". A caller-set @@ -122,7 +130,13 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process } catch { return out; } - if (!cfg.database_url) return out; + if (!cfg.database_url) { + if (cfg.engine === "pglite" || cfg.database_path) { + out.DATABASE_URL = ""; + out.GBRAIN_DATABASE_URL = ""; + } + return out; + } const hadCaller = baseEnv.DATABASE_URL !== undefined; const alreadyMatch = baseEnv.DATABASE_URL === cfg.database_url; @@ -249,6 +263,19 @@ export function gbrainInvocation(args: string[]): { cmd: string; argv: string[]; : { cmd: "gbrain", argv: args, shell: false }; } +/** + * Working directory for a gbrain child (#1917). gbrain (a Bun program) and + * its own config loader read `.env` / `.env.local` from the working + * directory, so a child started inside the user's project can pick the + * project's DATABASE_URL back up. Children run from gbrain's config + * directory (or the OS temp dir before init) unless the caller names a cwd. + */ +export function gbrainChildCwd(cwd?: string, env: NodeJS.ProcessEnv = process.env): string { + if (cwd) return cwd; + const dir = gbrainConfigDir(env); + return existsSync(dir) ? dir : tmpdir(); +} + export interface SpawnGbrainOptions { /** Timeout in milliseconds. Defaults to 30s. */ timeout?: number; @@ -276,7 +303,7 @@ export function spawnGbrain(args: string[], opts: SpawnGbrainOptions = {}): Spaw return spawnSync(inv.cmd, inv.argv, { encoding: "utf-8", timeout: opts.timeout ?? 30_000, - cwd: opts.cwd, + cwd: gbrainChildCwd(opts.cwd, opts.baseEnv), stdio: opts.stdio || ["ignore", "pipe", "pipe"], env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }), shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) @@ -311,7 +338,7 @@ export function spawnGbrainAsync( const inv = gbrainInvocation(args); return spawn(inv.cmd, inv.argv, { stdio: opts.stdio || ["ignore", "pipe", "pipe"], - cwd: opts.cwd, + cwd: gbrainChildCwd(opts.cwd, opts.baseEnv), env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false }), shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) }); @@ -326,7 +353,7 @@ export function execGbrainText(args: string[], opts: SpawnGbrainOptions = {}): s return execFileSync(inv.cmd, inv.argv, { encoding: "utf-8", timeout: opts.timeout ?? 30_000, - cwd: opts.cwd, + cwd: gbrainChildCwd(opts.cwd, opts.baseEnv), stdio: opts.stdio || ["ignore", "pipe", "pipe"], env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }), shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts index ae6ebcd8c..d4f632d4f 100644 --- a/lib/gbrain-local-status.ts +++ b/lib/gbrain-local-status.ts @@ -48,7 +48,7 @@ import { import { atomicWriteSync } from "./fs-atomic"; import { homedir } from "os"; import { dirname, join } from "path"; -import { buildGbrainEnv, gbrainConfigDir, isExecTimeout, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec"; +import { buildGbrainEnv, gbrainChildCwd, gbrainConfigDir, isExecTimeout, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec"; import { resolveStateRoot } from "./state-root"; export type LocalEngineStatus = @@ -458,6 +458,7 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus { timeout: probeTimeoutMs(env), stdio: ["ignore", "pipe", "pipe"], env: buildGbrainEnv({ baseEnv: env ?? process.env }), + cwd: gbrainChildCwd(undefined, env ?? process.env), shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows }); return "ok"; diff --git a/lib/gbrain-sources.ts b/lib/gbrain-sources.ts index 34b42319c..831c1f8a6 100644 --- a/lib/gbrain-sources.ts +++ b/lib/gbrain-sources.ts @@ -12,7 +12,7 @@ import { execFileSync, spawnSync } from "child_process"; import { realpathSync } from "fs"; import { withErrorContext } from "./gstack-memory-helpers"; -import { execGbrainJson, gbrainInvocation } from "./gbrain-exec"; +import { execGbrainJson, gbrainChildCwd, gbrainInvocation } from "./gbrain-exec"; import { detectAutopilot, decideSourceRemove, @@ -123,6 +123,7 @@ export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState { timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], env, + cwd: gbrainChildCwd(undefined, env), shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) }); } catch (err) { @@ -230,6 +231,7 @@ export async function ensureSourceRegistered( encoding: "utf-8", timeout: 30_000, env, + cwd: gbrainChildCwd(undefined, env), shell: rmInv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) }); if (rm.status !== 0) { @@ -246,6 +248,7 @@ export async function ensureSourceRegistered( encoding: "utf-8", timeout: 30_000, env, + cwd: gbrainChildCwd(undefined, env), shell: addInv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) }); if (add.status !== 0) { @@ -273,6 +276,7 @@ export function sourcePageCount(id: string, env?: NodeJS.ProcessEnv): number | n timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], env, + cwd: gbrainChildCwd(undefined, env), shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting) }); } catch { diff --git a/test/build-gbrain-env.test.ts b/test/build-gbrain-env.test.ts index d97e3a8d2..d0034dce5 100644 --- a/test/build-gbrain-env.test.ts +++ b/test/build-gbrain-env.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { spawnSync } from "child_process"; import { buildGbrainEnv, isTransactionModePooler } from "../lib/gbrain-exec"; @@ -70,13 +71,30 @@ describe("buildGbrainEnv", () => { expect(result.DATABASE_URL).toBe("postgresql://app/db"); }); - it("returns caller env unchanged when config has no database_url field", () => { - writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ engine: "pglite" })); + it("returns caller env unchanged when config names neither a database_url nor PGLite", () => { + writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ embedding_model: "x" })); const baseEnv = { HOME: home, DATABASE_URL: "postgresql://app/db" }; const result = buildGbrainEnv({ baseEnv }); expect(result.DATABASE_URL).toBe("postgresql://app/db"); }); + for (const cfg of [{ engine: "pglite" }, { database_path: "/brain/db.pglite" }]) { + it(`blanks inherited database URLs for a PGLite config ${JSON.stringify(cfg)} (#1917)`, () => { + writeFileSync(join(gbrainHome, "config.json"), JSON.stringify(cfg)); + const baseEnv = { HOME: home, DATABASE_URL: "postgresql://app/db", GBRAIN_DATABASE_URL: "postgresql://app/other" }; + const result = buildGbrainEnv({ baseEnv }); + expect(result.DATABASE_URL).toBe(""); + expect(result.GBRAIN_DATABASE_URL).toBe(""); + expect(baseEnv.DATABASE_URL).toBe("postgresql://app/db"); + }); + } + + it("keeps an inherited DATABASE_URL for a PGLite config under GSTACK_RESPECT_ENV_DATABASE_URL=1", () => { + writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ engine: "pglite" })); + const baseEnv = { HOME: home, DATABASE_URL: "postgresql://app/db", GSTACK_RESPECT_ENV_DATABASE_URL: "1" }; + expect(buildGbrainEnv({ baseEnv }).DATABASE_URL).toBe("postgresql://app/db"); + }); + it("honors GBRAIN_HOME when set, with gbrain's parent-dir semantics (#2521)", () => { // Move the config to an alternate dir; set GBRAIN_HOME to point at its // PARENT — gbrain's configDir() appends `.gbrain` itself, so @@ -210,3 +228,52 @@ describe("isTransactionModePooler", () => { )).toBe(true); }); }); + +// #1917: a real gbrain child launched by a Bun parent whose cwd is a project +// with conflicting dotenv files. Bun autoloads the project's .env into the +// parent, and a Bun child started in the project would autoload it again. +describe("gbrain children never see a project's dotenv database URL (#1917)", () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "gstack-gbrain-dotenv-")); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it("blanks the URLs and runs gbrain outside the project for every spawn helper", () => { + const home = join(root, "home"); + const project = join(root, "project"); + const bin = join(root, "bin"); + for (const dir of [join(home, ".gbrain"), project, bin]) mkdirSync(dir, { recursive: true }); + writeFileSync(join(home, ".gbrain", "config.json"), JSON.stringify({ engine: "pglite", database_path: join(home, ".gbrain", "brain.pglite") })); + writeFileSync(join(project, ".env"), "DATABASE_URL=postgresql://app:secret@localhost:5432/app_env\n"); + writeFileSync(join(project, ".env.local"), "GBRAIN_DATABASE_URL=postgresql://app:secret@localhost:5432/app_local\n"); + writeFileSync(join(bin, "gbrain"), `#!${process.execPath} +console.log(JSON.stringify({ db: process.env.DATABASE_URL ?? null, gdb: process.env.GBRAIN_DATABASE_URL ?? null, cwd: process.cwd() })); +`, { mode: 0o755 }); + const lib = join(import.meta.dir, "..", "lib", "gbrain-exec.ts"); + const parent = ` +import { spawnGbrain, execGbrainText, spawnGbrainAsync } from ${JSON.stringify(lib)}; +const out = { inherited: process.env.DATABASE_URL ?? null }; +out.sync = JSON.parse(spawnGbrain(["probe"]).stdout); +out.text = JSON.parse(execGbrainText(["probe"])); +const child = spawnGbrainAsync(["probe"]); +let buf = ""; +child.stdout.on("data", (c) => (buf += c)); +child.on("close", () => { out.async = JSON.parse(buf); console.log(JSON.stringify(out)); }); +`; + const r = spawnSync(process.execPath, ["-e", parent], { + cwd: project, + encoding: "utf-8", + timeout: 20_000, + env: { PATH: `${bin}:${process.env.PATH}`, HOME: home }, + }); + expect(r.stderr).toBe(""); + const out = JSON.parse(r.stdout); + expect(out.inherited).toContain("app_env"); + for (const key of ["sync", "text", "async"]) { + expect(out[key].db ?? "").toBe(""); + expect(out[key].gdb ?? "").toBe(""); + expect(out[key].cwd.startsWith(project)).toBe(false); + } + }); +});