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 <max.petrusenko.agent@gmail.com>
This commit is contained in:
garrytanandmaxpetrusenkoagent committed 2026-10-03 20:41:22 +00:00
1 parent 7e2edf2a3a
commit f20cd9a3a3
4 files changed
+109 -10

No files matched your search

+33 -6
View File
@@ -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)
+2 -1
View File
@@ -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";
+5 -1
View File
@@ -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 {
+69 -2
View File
@@ -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);
}
});
});