Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@ export type DbUrlSource =
| 'config-file-path' // PGLite: config file present, no URL but database_path set
| null;

// Lazy-evaluated to avoid calling homedir() at module scope (breaks in serverless/bundled environments)
function getConfigDir() { return join(homedir(), '.gbrain'); }
function getConfigPath() { return join(getConfigDir(), 'config.json'); }

export interface GBrainConfig {
engine: 'postgres' | 'pglite';
database_url?: string;
Expand All @@ -45,7 +41,7 @@ export interface GBrainConfig {
export function loadConfig(): GBrainConfig | null {
let fileConfig: GBrainConfig | null = null;
try {
const raw = readFileSync(getConfigPath(), 'utf-8');
const raw = readFileSync(configPath(), 'utf-8');
fileConfig = JSON.parse(raw) as GBrainConfig;
} catch { /* no config file */ }

Expand All @@ -64,15 +60,16 @@ export function loadConfig(): GBrainConfig | null {
engine: inferredEngine,
...(dbUrl ? { database_url: dbUrl } : {}),
...(process.env.OPENAI_API_KEY ? { openai_api_key: process.env.OPENAI_API_KEY } : {}),
...(process.env.ANTHROPIC_API_KEY ? { anthropic_api_key: process.env.ANTHROPIC_API_KEY } : {}),
};
return merged as GBrainConfig;
}

export function saveConfig(config: GBrainConfig): void {
mkdirSync(getConfigDir(), { recursive: true });
writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
mkdirSync(configDir(), { recursive: true });
writeFileSync(configPath(), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
try {
chmodSync(getConfigPath(), 0o600);
chmodSync(configPath(), 0o600);
} catch {
// chmod may fail on some platforms
}
Expand Down
11 changes: 10 additions & 1 deletion src/core/embedding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import OpenAI from 'openai';
import { loadConfig } from './config.ts';

const MODEL = 'text-embedding-3-large';
const DIMENSIONS = 1536;
Expand All @@ -21,7 +22,15 @@ let client: OpenAI | null = null;

function getClient(): OpenAI {
if (!client) {
client = new OpenAI();
// Prefer key from gbrain's own config (~/.gbrain/config.json). loadConfig()
// already merges OPENAI_API_KEY env var into the config for backward
// compatibility, so this covers both config-file and env-var users —
// and lets callers (cron jobs, agents, subprocess wrappers) run `gbrain`
// without needing to propagate the env var themselves.
const config = loadConfig();
client = config?.openai_api_key
? new OpenAI({ apiKey: config.openai_api_key })
: new OpenAI(); // SDK falls back to OPENAI_API_KEY env var if set
}
return client;
}
Expand Down
8 changes: 7 additions & 1 deletion src/core/search/expansion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import Anthropic from '@anthropic-ai/sdk';
import { loadConfig } from '../config.ts';

const MAX_QUERIES = 3;
const MIN_WORDS = 3;
Expand All @@ -24,7 +25,12 @@ let anthropicClient: Anthropic | null = null;

function getClient(): Anthropic {
if (!anthropicClient) {
anthropicClient = new Anthropic();
// Same pattern as embedding.ts: read key from gbrain's own config so
// subprocess callers don't need ANTHROPIC_API_KEY in their env.
const config = loadConfig();
anthropicClient = config?.anthropic_api_key
? new Anthropic({ apiKey: config.anthropic_api_key })
: new Anthropic(); // SDK falls back to ANTHROPIC_API_KEY env var if set
}
return anthropicClient;
}
Expand Down
8 changes: 6 additions & 2 deletions src/core/search/hybrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { embed } from '../embedding.ts';
import { dedupResults } from './dedup.ts';
import { autoDetectDetail } from './intent.ts';
import { expandAnchors, hydrateChunks } from './two-pass.ts';
import { loadConfig } from '../config.ts';

const RRF_K = 60;
const COMPILED_TRUTH_BOOST = 2.0;
Expand Down Expand Up @@ -85,8 +86,11 @@ export async function hybridSearch(
// Run keyword search (always available, no API key needed)
const keywordResults = await engine.searchKeyword(query, searchOpts);

// Skip vector search entirely if no OpenAI key is configured
if (!process.env.OPENAI_API_KEY) {
// Skip vector search entirely if no OpenAI key is available anywhere.
// Check both env (legacy) and config file so gbrain works as a
// self-contained subprocess for callers without env-var propagation.
const hasOpenAIKey = !!(process.env.OPENAI_API_KEY || loadConfig()?.openai_api_key);
if (!hasOpenAIKey) {
// Apply backlink boost in keyword-only path too. One getBacklinkCounts query
// per search request; not N+1.
if (keywordResults.length > 0) {
Expand Down
177 changes: 175 additions & 2 deletions test/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { readFileSync, mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { loadConfig, saveConfig, configDir, configPath } from '../src/core/config.ts';

// redactUrl is not exported, so we test it by reading the source and
// reimplementing the regex to verify the pattern, then test via CLI
Expand Down Expand Up @@ -61,3 +64,173 @@ describe('config source correctness', () => {
expect(configSource).toContain('postgresql:\\/\\/');
});
});

describe('loadConfig: API key merging (for self-contained subprocess use)', () => {
// These tests verify that gbrain can be called as a subprocess by agents/cron
// without the caller needing to propagate API keys — loadConfig picks them up
// from either the config file OR env vars, and both embedding.ts and
// expansion.ts read the merged config to instantiate their SDK clients.

let originalOpenAI: string | undefined;
let originalAnthropic: string | undefined;
let originalDatabaseUrl: string | undefined;

beforeEach(() => {
originalOpenAI = process.env.OPENAI_API_KEY;
originalAnthropic = process.env.ANTHROPIC_API_KEY;
originalDatabaseUrl = process.env.DATABASE_URL;
// Ensure loadConfig() returns something (needs DATABASE_URL when no file exists)
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test';
});

afterEach(() => {
if (originalOpenAI === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = originalOpenAI;
if (originalAnthropic === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = originalAnthropic;
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
else process.env.DATABASE_URL = originalDatabaseUrl;
});

test('merges OPENAI_API_KEY from env into config', () => {
process.env.OPENAI_API_KEY = 'sk-test-openai-xyz';
const config = loadConfig();
expect(config?.openai_api_key).toBe('sk-test-openai-xyz');
});

test('merges ANTHROPIC_API_KEY from env into config (regression: was missing)', () => {
// Before this fix, loadConfig() only merged OPENAI_API_KEY and silently
// dropped ANTHROPIC_API_KEY from the env. That meant subprocess callers
// who set ANTHROPIC_API_KEY in their shell still couldn't get query
// expansion because downstream code only saw the un-merged config.
process.env.ANTHROPIC_API_KEY = 'sk-ant-test-xyz';
const config = loadConfig();
expect(config?.anthropic_api_key).toBe('sk-ant-test-xyz');
});

test('merges both keys when both env vars set', () => {
process.env.OPENAI_API_KEY = 'sk-o';
process.env.ANTHROPIC_API_KEY = 'sk-a';
const config = loadConfig();
expect(config?.openai_api_key).toBe('sk-o');
expect(config?.anthropic_api_key).toBe('sk-a');
});

test('env-specific values do not leak after env deletion (file keys may still exist)', () => {
// Set recognizable env-specific sentinels.
process.env.OPENAI_API_KEY = 'sk-o-env-sentinel';
process.env.ANTHROPIC_API_KEY = 'sk-a-env-sentinel';
loadConfig();
delete process.env.OPENAI_API_KEY;
delete process.env.ANTHROPIC_API_KEY;
const config = loadConfig();
// After deletion, env sentinels must not leak. The file may legitimately
// provide keys (v0.12's self-contained API keys feature), which is fine —
// just not the sentinel values from the previous env-driven call.
expect(config?.openai_api_key).not.toBe('sk-o-env-sentinel');
expect(config?.anthropic_api_key).not.toBe('sk-a-env-sentinel');
});
});

describe('loadConfig / saveConfig: GBRAIN_HOME override', () => {
// Regression: before this fix, loadConfig/saveConfig used a private
// getConfigDir/getConfigPath that called homedir() directly and ignored
// GBRAIN_HOME, so config-file API keys were invisible in tests, Docker, and
// multi-tenant deployments — exactly the contexts that motivated GBRAIN_HOME.
// configDir/configPath already honored GBRAIN_HOME; this test pins that
// loadConfig and saveConfig now go through the same path.

let originalGbrainHome: string | undefined;
let originalOpenAI: string | undefined;
let originalAnthropic: string | undefined;
let originalDatabaseUrl: string | undefined;
let originalGbrainDatabaseUrl: string | undefined;
let tmpHome: string;

beforeEach(() => {
originalGbrainHome = process.env.GBRAIN_HOME;
originalOpenAI = process.env.OPENAI_API_KEY;
originalAnthropic = process.env.ANTHROPIC_API_KEY;
originalDatabaseUrl = process.env.DATABASE_URL;
originalGbrainDatabaseUrl = process.env.GBRAIN_DATABASE_URL;
delete process.env.OPENAI_API_KEY;
delete process.env.ANTHROPIC_API_KEY;
delete process.env.DATABASE_URL;
delete process.env.GBRAIN_DATABASE_URL;
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-home-'));
process.env.GBRAIN_HOME = tmpHome;
});

afterEach(() => {
if (originalGbrainHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = originalGbrainHome;
if (originalOpenAI === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = originalOpenAI;
if (originalAnthropic === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = originalAnthropic;
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
else process.env.DATABASE_URL = originalDatabaseUrl;
if (originalGbrainDatabaseUrl === undefined) delete process.env.GBRAIN_DATABASE_URL;
else process.env.GBRAIN_DATABASE_URL = originalGbrainDatabaseUrl;
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
});

test('configDir and configPath both honor GBRAIN_HOME', () => {
expect(configDir()).toBe(join(tmpHome, '.gbrain'));
expect(configPath()).toBe(join(tmpHome, '.gbrain', 'config.json'));
});

test('saveConfig writes under GBRAIN_HOME (not real homedir)', () => {
saveConfig({
engine: 'pglite',
database_path: '/tmp/fake.db',
openai_api_key: 'sk-from-saved-config',
});
const written = join(tmpHome, '.gbrain', 'config.json');
expect(existsSync(written)).toBe(true);
const parsed = JSON.parse(readFileSync(written, 'utf-8'));
expect(parsed.openai_api_key).toBe('sk-from-saved-config');
});

test('loadConfig reads from GBRAIN_HOME-rooted config file', () => {
// Hand-write the config file as if a previous saveConfig (or an operator)
// had created it. This pins the read path independent of the write path.
mkdirSync(join(tmpHome, '.gbrain'), { recursive: true });
writeFileSync(join(tmpHome, '.gbrain', 'config.json'), JSON.stringify({
engine: 'pglite',
database_path: '/tmp/fake.db',
openai_api_key: 'sk-from-file-only',
anthropic_api_key: 'sk-ant-from-file-only',
}));
const config = loadConfig();
expect(config?.openai_api_key).toBe('sk-from-file-only');
expect(config?.anthropic_api_key).toBe('sk-ant-from-file-only');
});

test('loadConfig under a different GBRAIN_HOME does NOT see the original config', () => {
// Two sandboxed homes — write to one, load from the other. This is the
// multi-tenant / per-test isolation invariant.
saveConfig({ engine: 'pglite', openai_api_key: 'sk-tenant-A' });

const tmpHomeB = mkdtempSync(join(tmpdir(), 'gbrain-home-b-'));
try {
process.env.GBRAIN_HOME = tmpHomeB;
const config = loadConfig();
expect(config).toBeNull();
} finally {
rmSync(tmpHomeB, { recursive: true, force: true });
}
});

test('saveConfig + loadConfig round-trip under GBRAIN_HOME', () => {
saveConfig({
engine: 'postgres',
database_url: 'postgresql://test@localhost/test',
openai_api_key: 'sk-roundtrip',
});
const config = loadConfig();
expect(config?.engine).toBe('postgres');
expect(config?.database_url).toBe('postgresql://test@localhost/test');
expect(config?.openai_api_key).toBe('sk-roundtrip');
});
});